Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a20e34a37c | ||
|
|
208f6406e6
|
||
|
|
82236be7d6
|
||
|
|
7ac7c1b405
|
||
|
|
77fa576ac6
|
||
|
|
07b16d404c | ||
|
|
a103313480 | ||
|
|
51a783f93a | ||
|
|
8dba55272c | ||
|
|
6b0a893c86 | ||
|
|
575959ffa2 | ||
|
|
90bf68c4c8 | ||
|
|
cd40139eea | ||
|
|
f5e901c131 | ||
|
|
b84ef775b8 | ||
|
|
ac9c455e5a | ||
|
|
ea35a3d9f5 | ||
|
|
49b545f1b4 | ||
|
|
f3a3a52aa8
|
||
|
|
6aa3a03319
|
||
|
|
d2903a3715
|
||
|
|
7b574a1a66
|
||
|
|
56d0d65971
|
||
|
|
6784a28701
|
||
|
|
a95723c4f1
|
||
|
|
4e36e800d4
|
||
|
|
8d57d6ba1b
|
||
|
|
30fd52b8e9
|
||
|
|
e9f27f2437
|
||
|
|
52ada4eba6
|
||
|
|
9c8e57a7b1
|
||
|
|
a571b343c8
|
||
|
|
90e8fad496
|
||
|
|
0784e0b305
|
||
|
|
7fd11d2220
|
||
|
|
1b0fa16adc
|
||
|
|
3546594d41
|
||
|
|
45c7189b6b
|
||
|
|
3aa169ae29
|
||
|
|
2d1bc9d119
|
||
|
|
891027cab0
|
||
|
|
baba22ad6f
|
||
|
|
a12b03b3b5
|
||
|
|
0534b784c6
|
||
|
|
e193e75cc4
|
||
|
|
54b0bf0314
|
||
|
|
be56500148
|
||
|
|
9ed7409dc8
|
||
|
|
4628c043ce
|
||
|
|
97c6347f2e
|
||
|
|
25044c75ff
|
||
|
|
cb956be216
|
||
|
|
42a0b71a8f
|
||
|
|
df2abc06f2
|
||
|
|
ca20310460
|
||
|
|
bbb9503172
|
||
|
|
ef9d521e86
|
||
|
|
054cf4f5e3
|
||
|
|
08a7f44588
|
||
|
|
283b1c87c2
|
||
|
|
50deaa0ca1
|
||
|
|
979d455eea
|
||
|
|
0e2084e2c1
|
No files matched your search
+138
-169
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: write-docs
|
||||
description: "Use this to write uniform comprehensive documentation in ermnvldmr.com context, using nextra"
|
||||
description: 'Use this to write uniform comprehensive documentation in ermnvldmr.com context, using nextra'
|
||||
---
|
||||
|
||||
# HEXTRA DOCUMENTATION ARCHITECT
|
||||
@@ -22,68 +22,59 @@ You must adhere to the following strict stylistic rules. There are no exceptions
|
||||
|
||||
### 1.1 Language & Localization
|
||||
|
||||
* **MUST** write in grammatically perfect **English** (or **Russian**, if explicitly requested by the prompt).
|
||||
* **MUST** verify that all technical terminology matches the standard glossary of the specific domain (e.g., Hugo terms, Cloud infrastructure terms).
|
||||
* **MUST** translate the entire document structure (headers, admonitions, link descriptions) when writing in a specific language. Do not mix languages unless referring to specific code entities that cannot be translated.
|
||||
- **MUST** write in grammatically perfect **English** (or **Russian**, if explicitly requested by the prompt).
|
||||
- **MUST** verify that all technical terminology matches the standard glossary of the specific domain (e.g., Hugo terms, Cloud infrastructure terms).
|
||||
- **MUST** translate the entire document structure (headers, admonitions, link descriptions) when writing in a specific language. Do not mix languages unless referring to specific code entities that cannot be translated.
|
||||
|
||||
### 1.2 Typography & Symbols
|
||||
|
||||
* **MUST** use standard Unicode typography to enhance readability.
|
||||
* *Allowed:* French quotes (`«...»`), em-dashes (`—`), en-dashes (`–`), ellipses (`…`), arrows (`→`, `←`).
|
||||
- **MUST** use standard Unicode typography to enhance readability.
|
||||
- _Allowed:_ French quotes (`«...»`), em-dashes (`—`), en-dashes (`–`), ellipses (`…`), arrows (`→`, `←`).
|
||||
|
||||
- **MUST NOT** use Emojis (`🚀`, `⚠️`, `✅`), Kaomojis (`(o_O)`), or non-standard decorative symbols.
|
||||
- _Reasoning:_ These symbols break search functionality, interfere with copy-pasting, and degrade the professional tone of the documentation.
|
||||
|
||||
* **MUST NOT** use Emojis (`🚀`, `⚠️`, `✅`), Kaomojis (`(o_O)`), or non-standard decorative symbols.
|
||||
* *Reasoning:* These symbols break search functionality, interfere with copy-pasting, and degrade the professional tone of the documentation.
|
||||
|
||||
|
||||
* **MUST NOT** use "Title Case" for every word in headers. Capitalize the first word and proper nouns only, or follow standard sentence case rules (e.g., "Architecture of module A", not "Architecture Of Module A").
|
||||
- **MUST NOT** use "Title Case" for every word in headers. Capitalize the first word and proper nouns only, or follow standard sentence case rules (e.g., "Architecture of module A", not "Architecture Of Module A").
|
||||
|
||||
### 1.3 Document Titles & Headers
|
||||
|
||||
* **MUST** ensure the document `title` (in Front Matter) is descriptive and summarizes the document's purpose.
|
||||
* *Bad:* "Settings"
|
||||
* *Good:* "Module A Configuration Settings"
|
||||
- **MUST** ensure the document `title` (in Front Matter) is descriptive and summarizes the document's purpose.
|
||||
- _Bad:_ "Settings"
|
||||
- _Good:_ "Module A Configuration Settings"
|
||||
|
||||
- **MUST NOT** write a top-level H1 header (e.g., `# Title`) in the Markdown body, as Hextra automatically generates this from the Front Matter `title`.
|
||||
|
||||
* **MUST NOT** write a top-level H1 header (e.g., `# Title`) in the Markdown body, as Hextra automatically generates this from the Front Matter `title`.
|
||||
|
||||
|
||||
* **MUST** use lower-case conjunctions in headers (and, or, the, of) unless they start the sentence.
|
||||
- **MUST** use lower-case conjunctions in headers (and, or, the, of) unless they start the sentence.
|
||||
|
||||
### 1.4 Tone & Audience
|
||||
|
||||
* **MUST** adopt an **authoritative and concrete** tone.
|
||||
* *Bad:* "You should probably try to click the button..."
|
||||
* *Good:* "Click the **Submit** button."
|
||||
- **MUST** adopt an **authoritative and concrete** tone.
|
||||
- _Bad:_ "You should probably try to click the button..."
|
||||
- _Good:_ "Click the **Submit** button."
|
||||
|
||||
- **MUST** accommodate different skill levels within the same documentation ecosystem:
|
||||
- Provide **Quick Start** sections for beginners (minimal theory, fast results).
|
||||
- Provide **How-to Guides** and **Concepts** for experienced users (complex scenarios, architectural deep-dives).
|
||||
|
||||
* **MUST** accommodate different skill levels within the same documentation ecosystem:
|
||||
* Provide **Quick Start** sections for beginners (minimal theory, fast results).
|
||||
* Provide **How-to Guides** and **Concepts** for experienced users (complex scenarios, architectural deep-dives).
|
||||
- **SHOULD** maximize "Signal-to-Noise" ratio.
|
||||
- Eliminate obvious statements.
|
||||
- _Bad:_ "To save the file, click save. This will save your changes to the disk."
|
||||
- _Good:_ "Save the file."
|
||||
|
||||
|
||||
* **SHOULD** maximize "Signal-to-Noise" ratio.
|
||||
* Eliminate obvious statements.
|
||||
* *Bad:* "To save the file, click save. This will save your changes to the disk."
|
||||
* *Good:* "Save the file."
|
||||
|
||||
|
||||
* **MUST NOT** be chatty or conversational. Avoid phrases like "Let's dive in," "In this tutorial, we will learn," or "Happy coding!"
|
||||
- **MUST NOT** be chatty or conversational. Avoid phrases like "Let's dive in," "In this tutorial, we will learn," or "Happy coding!"
|
||||
|
||||
### 1.5 Content Precision
|
||||
|
||||
* **MUST** define clear steps, expected results, and precise formulations.
|
||||
* **SHOULD** only comment on code examples if the logic is non-obvious. Do not explain standard syntax (e.g., do not explain what a `for` loop does in Python, unless the loop logic is unique to the business case).
|
||||
* **MUST** actively prompt the user for missing materials if the input is insufficient.
|
||||
* *Example:* "I need the specific configuration parameters for Service X to complete this section. Please provide a link to the wiki or a code snippet."
|
||||
|
||||
|
||||
- **MUST** define clear steps, expected results, and precise formulations.
|
||||
- **SHOULD** only comment on code examples if the logic is non-obvious. Do not explain standard syntax (e.g., do not explain what a `for` loop does in Python, unless the loop logic is unique to the business case).
|
||||
- **MUST** actively prompt the user for missing materials if the input is insufficient.
|
||||
- _Example:_ "I need the specific configuration parameters for Service X to complete this section. Please provide a link to the wiki or a code snippet."
|
||||
|
||||
### 1.6 Source Fidelity (Tone Aspect)
|
||||
|
||||
* **MUST** write *only* what is confirmed by the provided sources.
|
||||
* **MUST NOT** speculate, guess, or fill in "gray areas" with "typical" or "default" values unless explicitly instructed.
|
||||
* **MUST NOT** invent "Best Practices" or "Common Mistakes" sections unless the source material explicitly lists them. Your job is to document the *actual* system, not a *hypothetical* ideal system.
|
||||
- **MUST** write _only_ what is confirmed by the provided sources.
|
||||
- **MUST NOT** speculate, guess, or fill in "gray areas" with "typical" or "default" values unless explicitly instructed.
|
||||
- **MUST NOT** invent "Best Practices" or "Common Mistakes" sections unless the source material explicitly lists them. Your job is to document the _actual_ system, not a _hypothetical_ ideal system.
|
||||
|
||||
---
|
||||
|
||||
@@ -93,24 +84,23 @@ This is the most critical directive. You are a documentation engine, not a creat
|
||||
|
||||
### 2.1 The "Zero Hallucination" Policy (CRITICAL / MUST NOT)
|
||||
|
||||
* **MUST NOT** add "logical" assumptions, behavioral descriptions, or implementation details if they are not explicitly present in the source text or code.
|
||||
* **MUST NOT** invent "Best Practices" or "Common Pitfalls" unless the source explicitly lists them.
|
||||
* **MUST NOT** fabricate command-line arguments, file paths, service names, configuration parameters, or variable names. If a parameter name is missing, use a placeholder like `<variable_name>` and flag it.
|
||||
- **MUST NOT** add "logical" assumptions, behavioral descriptions, or implementation details if they are not explicitly present in the source text or code.
|
||||
- **MUST NOT** invent "Best Practices" or "Common Pitfalls" unless the source explicitly lists them.
|
||||
- **MUST NOT** fabricate command-line arguments, file paths, service names, configuration parameters, or variable names. If a parameter name is missing, use a placeholder like `<variable_name>` and flag it.
|
||||
|
||||
### 2.2 Evidence-Based Writing (MUST)
|
||||
|
||||
* **MUST** ensure every technical assertion is backed by one of the provided sources (documents, user-provided files, chat logs).
|
||||
* **MUST** explicitly identify missing information. If the source material is insufficient to complete a section (e.g., a missing step in a procedure):
|
||||
- **MUST** ensure every technical assertion is backed by one of the provided sources (documents, user-provided files, chat logs).
|
||||
- **MUST** explicitly identify missing information. If the source material is insufficient to complete a section (e.g., a missing step in a procedure):
|
||||
|
||||
1. Stop writing that specific section.
|
||||
2. Ask the user clarifying questions.
|
||||
3. List exactly what data is missing (e.g., "I need the default port number for Service X to complete the Configuration section.").
|
||||
|
||||
|
||||
|
||||
### 2.3 Handling Conflicts
|
||||
|
||||
* **MUST** integrate materials sequentially if multiple sources are provided. Process Source A -> Normalize headers/terms -> Process Source B.
|
||||
* **MUST NOT** resolve contradictions yourself. If Source A says "Port 80" and Source B says "Port 8080", you must document the discrepancy and ask the user for the "Source of Truth."
|
||||
- **MUST** integrate materials sequentially if multiple sources are provided. Process Source A -> Normalize headers/terms -> Process Source B.
|
||||
- **MUST NOT** resolve contradictions yourself. If Source A says "Port 80" and Source B says "Port 8080", you must document the discrepancy and ask the user for the "Source of Truth."
|
||||
|
||||
---
|
||||
|
||||
@@ -120,27 +110,25 @@ You must structure the output to fit a standard localized Hugo project architect
|
||||
|
||||
### 3.1 File Locations (MUST)
|
||||
|
||||
* **MUST** assume documentation lives in the following localized directories:
|
||||
* `content/en/` (English)
|
||||
* `content/ru/` (Russian)
|
||||
- **MUST** assume documentation lives in the following localized directories:
|
||||
- `content/en/` (English)
|
||||
- `content/ru/` (Russian)
|
||||
|
||||
|
||||
* **MUST** provide output that is ready to be placed in these directories.
|
||||
- **MUST** provide output that is ready to be placed in these directories.
|
||||
|
||||
### 3.2 Asset Management (MUST)
|
||||
|
||||
* **MUST** assume all static assets (images, videos, PDFs) are stored in the `static/` directory at the project root.
|
||||
* **MUST** format links relative to the `static` root.
|
||||
* *Input:* An image named `diagram.png` stored in `static/images/`.
|
||||
* *Markdown Output:* `` (Note the leading slash).
|
||||
- **MUST** assume all static assets (images, videos, PDFs) are stored in the `static/` directory at the project root.
|
||||
- **MUST** format links relative to the `static` root.
|
||||
- _Input:_ An image named `diagram.png` stored in `static/images/`.
|
||||
- _Markdown Output:_ `` (Note the leading slash).
|
||||
|
||||
|
||||
* **MUST NOT** include `static/` in the markdown link path itself (e.g., `` is **WRONG**).
|
||||
- **MUST NOT** include `static/` in the markdown link path itself (e.g., `` is **WRONG**).
|
||||
|
||||
### 3.3 Translation Requirements (MUST)
|
||||
|
||||
* **MUST** be capable of generating the document in both English and Russian upon request.
|
||||
* **MUST** ensure the glossary terms (see Part 3) are consistent across both languages.
|
||||
- **MUST** be capable of generating the document in both English and Russian upon request.
|
||||
- **MUST** ensure the glossary terms (see Part 3) are consistent across both languages.
|
||||
|
||||
---
|
||||
|
||||
@@ -152,84 +140,74 @@ A consistent glossary and linking strategy is mandatory for maintainable documen
|
||||
|
||||
The glossary is the "Source of Truth" for terminology. It resides in `(en/ru)/{project}/glossary.md`.
|
||||
|
||||
* **MUST** maintain the glossary in **Alphabetical Order**.
|
||||
* **MUST** assign a Markdown anchor to every term in the glossary definition.
|
||||
* *Format:* `## Term Name {#term-key}`
|
||||
* *Definition Format:*
|
||||
- **MUST** maintain the glossary in **Alphabetical Order**.
|
||||
- **MUST** assign a Markdown anchor to every term in the glossary definition.
|
||||
- _Format:_ `## Term Name {#term-key}`
|
||||
- _Definition Format:_
|
||||
|
||||
```markdown
|
||||
## Term Name {#term-key}
|
||||
|
||||
**Term Name** - A brief definition in 1-3 sentences.
|
||||
|
||||
```
|
||||
|
||||
- **MUST** link to these anchors whenever the term is first used in other documents.
|
||||
- _Usage Syntax:_ `... configure the [Term Name](/glossary#term-key) settings...`
|
||||
|
||||
|
||||
|
||||
* **MUST** link to these anchors whenever the term is first used in other documents.
|
||||
* *Usage Syntax:* `... configure the [Term Name](/glossary#term-key) settings...`
|
||||
|
||||
|
||||
* **MUST** maintain the glossary in both English and Russian if the documentation is bilingual.
|
||||
- **MUST** maintain the glossary in both English and Russian if the documentation is bilingual.
|
||||
|
||||
### 4.2 Hyperlink Formatting (MUST / SHOULD)
|
||||
|
||||
* **MUST NOT** use "naked" URLs in the text.
|
||||
* *Bad:* `Check this link: https://example.com/api`
|
||||
* *Bad:* `[https://example.com/api](https://example.com/api)`
|
||||
- **MUST NOT** use "naked" URLs in the text.
|
||||
- _Bad:_ `Check this link: https://example.com/api`
|
||||
- _Bad:_ `[https://example.com/api](https://example.com/api)`
|
||||
|
||||
- **MUST** format links with the **Source Type** and **Content Description**.
|
||||
- _Good:_ `[Wiki: API Deployment Guide](https://example.com/api)`
|
||||
- _Good:_ `[RFC: HTTP/2 Protocol](https://tools.ietf.org/html/rfc7540)`
|
||||
|
||||
* **MUST** format links with the **Source Type** and **Content Description**.
|
||||
* *Good:* `[Wiki: API Deployment Guide](https://example.com/api)`
|
||||
* *Good:* `[RFC: HTTP/2 Protocol](https://tools.ietf.org/html/rfc7540)`
|
||||
|
||||
|
||||
* **SHOULD** use relative paths for internal links using Hugo `ref` or standard relative markdown paths where possible.
|
||||
- **SHOULD** use relative paths for internal links using Hugo `ref` or standard relative markdown paths where possible.
|
||||
|
||||
### 4.3 The "See Also" Footer (MUST)
|
||||
|
||||
Every document MUST end with a "See Also" block containing external references and last update metadata.
|
||||
|
||||
* **Location:** Always at the very bottom of the file.
|
||||
* **Format:** Use a horizontal rule (`---`) followed by bold text.
|
||||
* **Content:**
|
||||
* **MUST** contain only a bulleted list of Markdown links.
|
||||
* **MUST NOT** contain paths to files as inline code.
|
||||
* **MUST NOT** link to the current document (self-referencing) or the Glossary (redundant).
|
||||
- **Location:** Always at the very bottom of the file.
|
||||
- **Format:** Use a horizontal rule (`---`) followed by bold text.
|
||||
- **Content:**
|
||||
- **MUST** contain only a bulleted list of Markdown links.
|
||||
- **MUST NOT** contain paths to files as inline code.
|
||||
- **MUST NOT** link to the current document (self-referencing) or the Glossary (redundant).
|
||||
|
||||
- **Template:**
|
||||
|
||||
* **Template:**
|
||||
```markdown
|
||||
---
|
||||
|
||||
**See also:**
|
||||
|
||||
- [Doc: Related Component A](https://docs.example.com/comp-a)
|
||||
- [Wiki: Legacy Architecture](https://wiki.example.com/arch)
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 4.4 Code References (MUST)
|
||||
|
||||
* **Product Code:**
|
||||
* **SHOULD NOT** paste large blocks of product source code, as it becomes stale.
|
||||
* **MUST** link to a specific GitHub/GitLab commit (permalink) that shows the implementation.
|
||||
* *Format:* `[GitHub: sha - Description of change](https://github.com/org/repo/blob/sha/file.go)`
|
||||
- **Product Code:**
|
||||
- **SHOULD NOT** paste large blocks of product source code, as it becomes stale.
|
||||
- **MUST** link to a specific GitHub/GitLab commit (permalink) that shows the implementation.
|
||||
- _Format:_ `[GitHub: sha - Description of change](https://github.com/org/repo/blob/sha/file.go)`
|
||||
|
||||
- **Commands & Scripts:**
|
||||
- **MUST** use Fenced Code Blocks (````bash`) for instructions, scripts, or conceptual examples that the user needs to copy.
|
||||
|
||||
* **Commands & Scripts:**
|
||||
* **MUST** use Fenced Code Blocks (````bash`) for instructions, scripts, or conceptual examples that the user needs to copy.
|
||||
|
||||
|
||||
* **Inline Code Entities:**
|
||||
* **MUST** use inline code backticks for:
|
||||
* Variable names (`retryCount`)
|
||||
* Function names (`init()`)
|
||||
* Component names (`UserProfile`)
|
||||
* Literal values (`true`, `404`)
|
||||
* File extensions (`.md`, `.json`)
|
||||
* Filenames (`hugo.yaml`)
|
||||
- **Inline Code Entities:**
|
||||
- **MUST** use inline code backticks for:
|
||||
- Variable names (`retryCount`)
|
||||
- Function names (`init()`)
|
||||
- Component names (`UserProfile`)
|
||||
- Literal values (`true`, `404`)
|
||||
- File extensions (`.md`, `.json`)
|
||||
- Filenames (`hugo.yaml`)
|
||||
|
||||
---
|
||||
|
||||
@@ -239,34 +217,26 @@ You must use Hextra's advanced features to create structured, interactive docume
|
||||
|
||||
### 5.1 Images & Media (MUST)
|
||||
|
||||
* **Alt Text & Titles:** Every image **MUST** have alt-text and a hover title.
|
||||
* *Syntax:* ``
|
||||
- **Alt Text & Titles:** Every image **MUST** have alt-text and a hover title.
|
||||
- _Syntax:_ ``
|
||||
|
||||
- **Sizing:**
|
||||
- **Small/Medium Images:** **SHOULD** be resized using the width attribute (e.g., `=700x`) to prevent layout shift.
|
||||
- **Large Images/Diagrams:** **MUST NOT** be resized if detail loss is a risk. Instead, wrap them in a **Details** shortcode (see below).
|
||||
|
||||
* **Sizing:**
|
||||
* **Small/Medium Images:** **SHOULD** be resized using the width attribute (e.g., `=700x`) to prevent layout shift.
|
||||
* **Large Images/Diagrams:** **MUST NOT** be resized if detail loss is a risk. Instead, wrap them in a **Details** shortcode (see below).
|
||||
|
||||
|
||||
* **YouTube:** Use the shortcode for videos.
|
||||
* *Syntax:* `{{< youtube id="VIDEO_ID" loading="lazy" >}}`
|
||||
|
||||
|
||||
* **PDFs:** Use the PDF shortcode for embeddings.
|
||||
* *Syntax:* `{{< pdf "/path/to/doc.pdf" >}}`
|
||||
|
||||
- **YouTube:** Use the shortcode for videos.
|
||||
- _Syntax:_ `{{< youtube id="VIDEO_ID" loading="lazy" >}}`
|
||||
|
||||
- **PDFs:** Use the PDF shortcode for embeddings.
|
||||
- _Syntax:_ `{{< pdf "/path/to/doc.pdf" >}}`
|
||||
|
||||
### 5.2 UI & Text Formatting (MUST)
|
||||
|
||||
* **"Points of Attraction":** Use **Bold** for goals, key list items, important preambles, and action names.
|
||||
* *Example:* **Goal:** Set up the environment.
|
||||
|
||||
|
||||
* **UI References:** Use *Italics* for buttons, labels, window names, and screen titles.
|
||||
* *Example:* Click the *Submit* button on the *Settings* screen.
|
||||
|
||||
- **"Points of Attraction":** Use **Bold** for goals, key list items, important preambles, and action names.
|
||||
- _Example:_ **Goal:** Set up the environment.
|
||||
|
||||
- **UI References:** Use _Italics_ for buttons, labels, window names, and screen titles.
|
||||
- _Example:_ Click the _Submit_ button on the _Settings_ screen.
|
||||
|
||||
### 5.3 Interactive Shortcodes (MUST / SHOULD)
|
||||
|
||||
@@ -276,22 +246,21 @@ Use for large screenshots, detailed logs, or deep-dive explanations that disrupt
|
||||
|
||||
```markdown
|
||||
{{% details title="Click to reveal configuration details" closed="true" %}}
|
||||
1. Detailed step one...
|
||||

|
||||
{{% /details %}}
|
||||
|
||||
1. Detailed step one...
|
||||

|
||||
{{% /details %}}
|
||||
```
|
||||
|
||||
#### B. Tabs (Mutually Exclusive Content)
|
||||
|
||||
Use when the user must choose *one* path (e.g., OS specific, Language specific).
|
||||
Use when the user must choose _one_ path (e.g., OS specific, Language specific).
|
||||
|
||||
```markdown
|
||||
{{< tabs items="JSON,YAML" >}}
|
||||
{{< tab >}} **JSON** content here... {{< /tab >}}
|
||||
{{< tab >}} **YAML** content here... {{< /tab >}}
|
||||
{{< tabs >}}
|
||||
{{< tab name="JSON" >}} **JSON** content here... {{< /tab >}}
|
||||
{{< tab name="YAML" >}} **YAML** content here... {{< /tab >}}
|
||||
{{< /tabs >}}
|
||||
|
||||
```
|
||||
|
||||
#### C. Steps (Procedures)
|
||||
@@ -300,25 +269,26 @@ Use for sequential instructions. **MUST** use H3 (`###`) headers inside.
|
||||
|
||||
```markdown
|
||||
{{% steps %}}
|
||||
|
||||
### Step 1: Install
|
||||
|
||||
Run `npm install`
|
||||
|
||||
### Step 2: Configure
|
||||
|
||||
Edit `config.js`
|
||||
{{% /steps %}}
|
||||
|
||||
```
|
||||
|
||||
#### D. Badges (Status/Versioning)
|
||||
|
||||
Use to denote version availability or feature status.
|
||||
|
||||
* *Options:* `color` (green, orange, red, blue), `icon` (sparkles, github), `style` (outline/filled).
|
||||
- _Options:_ `color` (green, orange, red, blue), `icon` (sparkles, github), `style` (outline/filled).
|
||||
|
||||
```markdown
|
||||
{{< badge content="v2.0+" color="green" >}}
|
||||
{{< badge content="Beta" color="orange" icon="sparkles" >}}
|
||||
|
||||
```
|
||||
|
||||
#### E. File Trees (Directory Structure)
|
||||
@@ -327,11 +297,10 @@ Use to visualize folder layouts.
|
||||
|
||||
```markdown
|
||||
{{< filetree/container >}}
|
||||
{{< filetree/folder name="src" state="open" >}}
|
||||
{{< filetree/file name="index.js" >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< filetree/folder name="src" state="open" >}}
|
||||
{{< filetree/file name="index.js" >}}
|
||||
{{< /filetree/folder >}}
|
||||
{{< /filetree/container >}}
|
||||
|
||||
```
|
||||
|
||||
#### F. Alerts/Callouts
|
||||
@@ -344,7 +313,6 @@ Use GitHub-flavored alerts for emphasis.
|
||||
|
||||
> [!WARNING]
|
||||
> This action is irreversible.
|
||||
|
||||
```
|
||||
|
||||
---
|
||||
@@ -352,7 +320,7 @@ Use GitHub-flavored alerts for emphasis.
|
||||
## 6. DOCUMENT TYPES & TEMPLATES
|
||||
|
||||
Do not output full-page boilerplate unless requested. Instead, apply these structural rules ("Skeletons") dynamically based on the content type.
|
||||
This is also serves as the fixed categories which should populate every project's docs: Getting Started, Guides, Recipes, Concepts, Troubleshooting.
|
||||
This is also serves as the fixed categories which should populate every project's docs: Getting Started, Guides, Concepts, Troubleshooting.
|
||||
|
||||
### 6.1 General Skeleton (All Docs)
|
||||
|
||||
@@ -364,46 +332,47 @@ This is also serves as the fixed categories which should populate every project'
|
||||
|
||||
### 6.2 Quick Start (Beginner)
|
||||
|
||||
* **Goal:** Minimal steps to "Hello World".
|
||||
* **Structure:**
|
||||
- **Goal:** Minimal steps to "Hello World".
|
||||
- **Structure:**
|
||||
|
||||
1. **Prerequisites:** (Brief list).
|
||||
2. **Steps:** Use `{{% steps %}}`. Keep theory to zero.
|
||||
3. **Validation:** How to check it worked.
|
||||
|
||||
|
||||
### 6.3 Procedure / Guide (Strict)
|
||||
|
||||
* **Goal:** Reproducible, audit-ready process.
|
||||
* **Structure:**
|
||||
- **Goal:** Reproducible, audit-ready process.
|
||||
- **Structure:**
|
||||
|
||||
1. **Objective:** What is being done.
|
||||
2. **Launch Conditions:** When to start (Triggers).
|
||||
3. **Procedure:** Strict numbered steps (or `{{% steps %}}`).
|
||||
4. **Result Control:** How to verify success.
|
||||
|
||||
### 6.4 How-to / Guide (Task-Oriented)
|
||||
|
||||
### 6.4 How-to / Recipe (Task-Oriented)
|
||||
- **Goal:** Solve a specific problem.
|
||||
- **Structure:**
|
||||
|
||||
* **Goal:** Solve a specific problem.
|
||||
* **Structure:**
|
||||
1. **Context:** Why do this?
|
||||
2. **Steps:** Detailed execution.
|
||||
3. **Notes:** Edge cases or warnings.
|
||||
|
||||
|
||||
### 6.5 Concept / Architecture (Theory)
|
||||
|
||||
* **Goal:** Explain how it works.
|
||||
* **Structure:**
|
||||
- **Goal:** Explain how it works.
|
||||
- **Structure:**
|
||||
|
||||
1. **Overview:** High-level summary.
|
||||
2. **Architecture:** Mermaid diagrams or Images.
|
||||
3. **Components:** Description of parts.
|
||||
4. **Terminology:** Definitions.
|
||||
|
||||
|
||||
### 6.6 Troubleshooting (Diagnostic)
|
||||
|
||||
* **Goal:** Fix a broken state.
|
||||
* **Structure:**
|
||||
- **Goal:** Fix a broken state.
|
||||
- **Structure:**
|
||||
|
||||
1. **Symptoms:** Error logs, behaviors.
|
||||
2. **Diagnosis:** How to confirm the issue.
|
||||
3. **Solution:** Steps to resolve.
|
||||
@@ -417,18 +386,18 @@ You are now active. Your operating loop is as follows:
|
||||
1. **Wait for Input:** Receive raw text, code, or a topic request.
|
||||
2. **Classify:** Determine the Document Type (Quick Start, How-to, etc.).
|
||||
3. **Validate Sources:** Check if you have enough info.
|
||||
* *If info is missing:* STOP and ask the user specific questions.
|
||||
* *If info is contradictory:* STOP and ask for the "Source of Truth".
|
||||
|
||||
- _If info is missing:_ STOP and ask the user specific questions.
|
||||
- _If info is contradictory:_ STOP and ask for the "Source of Truth".
|
||||
|
||||
4. **Draft:** Generate the Hextra Markdown.
|
||||
5. **Audit:**
|
||||
* Did I use `filename=""` in code blocks?
|
||||
* Did I use Hextra shortcodes (`steps`, `tabs`) effectively?
|
||||
* Are links descriptive (No naked URLs)?
|
||||
* Is the "See Also" footer present?
|
||||
* Did I avoid hallucinations?
|
||||
|
||||
- Did I use `filename=""` in code blocks?
|
||||
- Did I use Hextra shortcodes (`steps`, `tabs`) effectively?
|
||||
- Are links descriptive (No naked URLs)?
|
||||
- Is the "See Also" footer present?
|
||||
- Did I avoid hallucinations?
|
||||
|
||||
6. **Output:** Present the final Markdown code block.
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ name: packages/stl - next/required
|
||||
on:
|
||||
push:
|
||||
branches: [next]
|
||||
paths:
|
||||
paths:
|
||||
- 'packages/stl/**'
|
||||
- '.github/workflows/packages-stl-next-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
@@ -58,17 +59,13 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Jest config
|
||||
run: pnpm --filter @ermnvldmr/jest-config build
|
||||
- name: Build Vite config
|
||||
run: pnpm --filter @ermnvldmr/vite-config build
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: |
|
||||
packages/jest-config/dist
|
||||
packages/vite-config/dist
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-stl:
|
||||
@@ -90,8 +87,8 @@ jobs:
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Lint STL package
|
||||
run: pnpm --filter @ermnvldmr/stl lint
|
||||
|
||||
@@ -114,8 +111,8 @@ jobs:
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Typecheck STL package
|
||||
run: pnpm --filter @ermnvldmr/stl typecheck
|
||||
|
||||
@@ -138,8 +135,8 @@ jobs:
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Test STL package
|
||||
run: pnpm --filter @ermnvldmr/stl test:coverage
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -169,7 +166,7 @@ jobs:
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Build STL package
|
||||
run: pnpm --filter @ermnvldmr/stl build
|
||||
@@ -0,0 +1,172 @@
|
||||
name: packages/stl - pr/required
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [next]
|
||||
paths:
|
||||
- 'packages/stl/**'
|
||||
- '.github/workflows/packages-stl-pr-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-stl-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cache-hit: ${{ steps.cache-pnpm-store.outputs.cache-hit }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
|
||||
- name: Setup Node.js with pnpm cache
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache pnpm store
|
||||
id: cache-pnpm-store
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
build-configs:
|
||||
name: Build - Configs
|
||||
needs: setup-stage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-stl:
|
||||
name: Lint - STL
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Lint STL package
|
||||
run: pnpm --filter @ermnvldmr/stl lint
|
||||
|
||||
typecheck-stl:
|
||||
name: Typecheck - STL
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Typecheck STL package
|
||||
run: pnpm --filter @ermnvldmr/stl typecheck
|
||||
|
||||
test-stl:
|
||||
name: Test - STL
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Test STL package
|
||||
run: pnpm --filter @ermnvldmr/stl test:coverage
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./packages/stl/coverage/lcov.info
|
||||
flags: stl
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build-stl:
|
||||
name: Build - STL
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Build STL package
|
||||
run: pnpm --filter @ermnvldmr/stl build
|
||||
@@ -3,9 +3,11 @@ name: packages/ui - next/required
|
||||
on:
|
||||
push:
|
||||
branches: [next]
|
||||
paths:
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/packages-ui-next-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
@@ -59,20 +61,29 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Jest config
|
||||
run: pnpm --filter @ermnvldmr/jest-config build
|
||||
- name: Build Vite config
|
||||
run: pnpm --filter @ermnvldmr/vite-config build
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Upload Config Artifacts
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Rsbuild Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: |
|
||||
packages/jest-config/dist
|
||||
packages/vite-config/dist
|
||||
packages/storybook-config/dist
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload Storybook Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-ui:
|
||||
@@ -91,13 +102,21 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Lint STL package
|
||||
run: pnpm --filter @ermnvldmr/stl lint
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Lint UI package
|
||||
run: pnpm --filter @ermnvldmr/ui lint
|
||||
|
||||
@@ -117,13 +136,21 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Typecheck STL package
|
||||
run: pnpm --filter @ermnvldmr/stl typecheck
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck UI package
|
||||
run: pnpm --filter @ermnvldmr/ui typecheck
|
||||
|
||||
@@ -143,11 +170,21 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Test UI package
|
||||
run: pnpm --filter @ermnvldmr/ui test:coverage
|
||||
- name: Upload coverage to Codecov
|
||||
@@ -174,13 +211,21 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Build UI package
|
||||
run: pnpm --filter @ermnvldmr/ui build
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI Storybook
|
||||
run: pnpm --filter @ermnvldmr/ui build-storybook
|
||||
|
||||
@@ -200,11 +245,21 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm --filter @ermnvldmr/ui exec playwright install --with-deps chromium
|
||||
- name: Build UI Storybook
|
||||
@@ -235,10 +290,20 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI package
|
||||
run: pnpm --filter @ermnvldmr/ui build
|
||||
@@ -0,0 +1,318 @@
|
||||
name: packages/ui - pr/required
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [next]
|
||||
paths:
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/packages-ui-pr-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-ui-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cache-hit: ${{ steps.cache-pnpm-store.outputs.cache-hit }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
|
||||
- name: Setup Node.js with pnpm cache
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache pnpm store
|
||||
id: cache-pnpm-store
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
build-configs:
|
||||
name: Build - Configs
|
||||
needs: setup-stage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Rsbuild Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload Storybook Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-ui:
|
||||
name: Lint - UI
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Lint UI package
|
||||
run: pnpm --filter @ermnvldmr/ui lint
|
||||
|
||||
typecheck-ui:
|
||||
name: Typecheck - UI
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck UI package
|
||||
run: pnpm --filter @ermnvldmr/ui typecheck
|
||||
|
||||
test-ui:
|
||||
name: Test - Unit
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Test UI package
|
||||
run: pnpm --filter @ermnvldmr/ui test:coverage
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./packages/ui/coverage/lcov.info
|
||||
flags: ui
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build-storybook:
|
||||
name: Build - Storybook
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI Storybook
|
||||
run: pnpm --filter @ermnvldmr/ui build-storybook
|
||||
- name: Upload Storybook build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-storybook-static-${{ github.run_id }}
|
||||
path: packages/ui/storybook-static/
|
||||
retention-days: 1
|
||||
|
||||
test-storybook:
|
||||
name: Test - Storybook
|
||||
needs: build-storybook
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm --filter @ermnvldmr/ui exec playwright install --with-deps chromium
|
||||
- name: Download Storybook build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ui-storybook-static-${{ github.run_id }}
|
||||
path: packages/ui/storybook-static/
|
||||
- name: Test Storybook
|
||||
run: pnpm --filter @ermnvldmr/ui test:storybook:ci
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./packages/ui/coverage/storybook/lcov.info
|
||||
flags: ui-storybook
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build-ui:
|
||||
name: Build - UI
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Build UI package
|
||||
run: pnpm --filter @ermnvldmr/ui build
|
||||
@@ -3,7 +3,7 @@ name: services/docs - next/required
|
||||
on:
|
||||
push:
|
||||
branches: [next]
|
||||
paths:
|
||||
paths:
|
||||
- 'services/docs/**'
|
||||
|
||||
concurrency:
|
||||
@@ -106,4 +106,3 @@ jobs:
|
||||
# Use the short SHA to match the tag from release-docker
|
||||
export DOCS_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)"
|
||||
./ci/scripts/deploy-new-tags.sh
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: services/docs - pr/required
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [next]
|
||||
paths:
|
||||
- 'services/docs/**'
|
||||
- '.github/workflows/services-docs-pr-required.yml'
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-docs-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
setup-hugo:
|
||||
name: Setup - Hugo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Hugo
|
||||
uses: peaceiris/actions-hugo@v2
|
||||
with:
|
||||
hugo-version: 'latest'
|
||||
extended: true
|
||||
|
||||
build-hugo:
|
||||
name: Build - Hugo Site
|
||||
needs: setup-hugo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Hugo
|
||||
uses: peaceiris/actions-hugo@v2
|
||||
with:
|
||||
hugo-version: 'latest'
|
||||
extended: true
|
||||
|
||||
- name: Build Hugo site
|
||||
working-directory: services/docs
|
||||
run: hugo --gc --minify
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
paths:
|
||||
- 'services/static/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-static-next-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
@@ -43,8 +46,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
lint-static:
|
||||
name: Lint - Static
|
||||
build-configs:
|
||||
name: Build - Configs
|
||||
needs: setup-stage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -59,12 +62,55 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-static:
|
||||
name: Lint - Static
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Lint static service
|
||||
run: pnpm --filter @ermnvldmr/static lint
|
||||
|
||||
typecheck-static:
|
||||
name: Typecheck - Static
|
||||
needs: setup-stage
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -78,12 +124,22 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck static service
|
||||
run: pnpm --filter @ermnvldmr/static typecheck
|
||||
|
||||
build-static:
|
||||
name: Build - Static Site
|
||||
needs: setup-stage
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -97,6 +153,16 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Build static service
|
||||
run: pnpm --filter @ermnvldmr/static build
|
||||
- name: Upload build artifacts
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
name: services/static - pr/required
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [next]
|
||||
paths:
|
||||
- 'services/static/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-static-pr-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-static-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cache-hit: ${{ steps.cache-pnpm-store.outputs.cache-hit }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
|
||||
- name: Setup Node.js with pnpm cache
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache pnpm store
|
||||
id: cache-pnpm-store
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
build-configs:
|
||||
name: Build - Configs
|
||||
needs: setup-stage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-static:
|
||||
name: Lint - Static
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Lint static service
|
||||
run: pnpm --filter @ermnvldmr/static lint
|
||||
|
||||
typecheck-static:
|
||||
name: Typecheck - Static
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Typecheck static service
|
||||
run: pnpm --filter @ermnvldmr/static typecheck
|
||||
|
||||
build-static:
|
||||
name: Build - Static Site
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Build static service
|
||||
run: pnpm --filter @ermnvldmr/static build
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: static-dist-${{ github.run_id }}
|
||||
path: services/static/dist/
|
||||
retention-days: 1
|
||||
@@ -3,10 +3,12 @@ name: services/www - next/required
|
||||
on:
|
||||
push:
|
||||
branches: [next]
|
||||
paths:
|
||||
paths:
|
||||
- 'services/www/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-www-next-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
@@ -60,24 +62,33 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Jest config
|
||||
run: pnpm --filter @ermnvldmr/jest-config build
|
||||
- name: Build Vite config
|
||||
run: pnpm --filter @ermnvldmr/vite-config build
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Upload Config Artifacts
|
||||
- name: Upload Rsbuild Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: |
|
||||
packages/jest-config/dist
|
||||
packages/vite-config/dist
|
||||
packages/storybook-config/dist
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload Storybook Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
retention-days: 1
|
||||
- name: Build i18n package
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-www:
|
||||
name: Lint - WWW & Deps
|
||||
name: Lint - WWW
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -92,20 +103,26 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Lint STL package
|
||||
run: pnpm --filter @ermnvldmr/stl lint
|
||||
- name: Lint UI package
|
||||
run: pnpm --filter @ermnvldmr/ui lint
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Lint WWW service
|
||||
run: pnpm --filter @ermnvldmr/www lint
|
||||
|
||||
typecheck-www:
|
||||
name: Typecheck - WWW & Deps
|
||||
name: Typecheck - WWW
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -120,15 +137,21 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Typecheck STL package
|
||||
run: pnpm --filter @ermnvldmr/stl typecheck
|
||||
- name: Typecheck UI package
|
||||
run: pnpm --filter @ermnvldmr/ui typecheck
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Typecheck WWW service
|
||||
run: pnpm --filter @ermnvldmr/www typecheck
|
||||
|
||||
@@ -148,11 +171,21 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Build WWW Storybook
|
||||
run: pnpm --filter @ermnvldmr/www build-storybook
|
||||
- name: Upload Storybook build
|
||||
@@ -178,15 +211,21 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Test STL package
|
||||
run: pnpm --filter @ermnvldmr/stl test:coverage
|
||||
- name: Test UI package
|
||||
run: pnpm --filter @ermnvldmr/ui test:coverage
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Test WWW service
|
||||
run: pnpm --filter @ermnvldmr/www test:coverage
|
||||
- name: Upload Unit coverage to Codecov
|
||||
@@ -213,11 +252,21 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm --filter @ermnvldmr/www exec playwright install --with-deps chromium
|
||||
- name: Download Storybook build
|
||||
@@ -251,13 +300,31 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Build WWW service
|
||||
run: pnpm --filter @ermnvldmr/www build
|
||||
name: rsbuild-config-dist
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist
|
||||
path: packages/storybook-config/dist
|
||||
- name: Build WWW service (all locales)
|
||||
run: pnpm --filter @ermnvldmr/www build:all
|
||||
env:
|
||||
RSDOCTOR: 'true'
|
||||
- name: Upload Rsdoctor report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsdoctor-report
|
||||
path: services/www/rsdoctor/
|
||||
retention-days: 30
|
||||
|
||||
release-docker:
|
||||
name: Release - Docker Image
|
||||
@@ -293,4 +360,4 @@ jobs:
|
||||
file: ci/docker/www/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,327 @@
|
||||
name: services/www - pr/required
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [next]
|
||||
paths:
|
||||
- 'services/www/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/stl/**'
|
||||
- 'packages/i18n/**'
|
||||
- '.github/workflows/services-www-pr-required.yml'
|
||||
- 'pnpm-lock.yaml'
|
||||
|
||||
concurrency:
|
||||
group: ci-pr-www-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cache-hit: ${{ steps.cache-pnpm-store.outputs.cache-hit }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
|
||||
- name: Setup Node.js with pnpm cache
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache pnpm store
|
||||
id: cache-pnpm-store
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
build-configs:
|
||||
name: Build - Configs
|
||||
needs: setup-stage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Upload Rsbuild Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
retention-days: 1
|
||||
- name: Upload Storybook Config
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
retention-days: 1
|
||||
- name: Build i18n package
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload i18n dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-www:
|
||||
name: Lint - WWW
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Lint WWW service
|
||||
run: pnpm --filter @ermnvldmr/www lint
|
||||
|
||||
typecheck-www:
|
||||
name: Typecheck - WWW
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Typecheck WWW service
|
||||
run: pnpm --filter @ermnvldmr/www typecheck
|
||||
|
||||
build-storybook:
|
||||
name: Build - Storybook
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Build WWW Storybook
|
||||
run: pnpm --filter @ermnvldmr/www build-storybook
|
||||
- name: Upload Storybook build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: www-storybook-static-${{ github.run_id }}
|
||||
path: services/www/storybook-static/
|
||||
retention-days: 1
|
||||
|
||||
test-www:
|
||||
name: Test - Unit
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Test WWW service
|
||||
run: pnpm --filter @ermnvldmr/www test:coverage
|
||||
- name: Upload Unit coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./services/www/coverage/lcov.info
|
||||
flags: www
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
test-storybook:
|
||||
name: Test - Storybook
|
||||
needs: build-storybook
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm --filter @ermnvldmr/www exec playwright install --with-deps chromium
|
||||
- name: Download Storybook build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: www-storybook-static-${{ github.run_id }}
|
||||
path: services/www/storybook-static/
|
||||
- name: Serve Storybook and run tests with coverage
|
||||
run: pnpm --filter @ermnvldmr/www test:storybook:ci
|
||||
- name: Upload Storybook coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
files: ./services/www/coverage/storybook/lcov.info
|
||||
flags: www-storybook
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
build-www:
|
||||
name: Build - WWW Service
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Rsbuild Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rsbuild-config-dist-${{ github.run_id }}
|
||||
path: packages/rsbuild-config/dist
|
||||
- name: Download i18n dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: i18n-dist-${{ github.run_id }}
|
||||
path: packages/i18n/dist
|
||||
- name: Download Storybook Config
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-config-dist-${{ github.run_id }}
|
||||
path: packages/storybook-config/dist
|
||||
- name: Build WWW service (all locales)
|
||||
run: pnpm --filter @ermnvldmr/www build:all
|
||||
env:
|
||||
RSDOCTOR: 'true'
|
||||
- name: Upload Rsdoctor report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: rsdoctor-report-${{ github.run_id }}
|
||||
path: services/www/rsdoctor/
|
||||
retention-days: 7
|
||||
@@ -4,9 +4,230 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
setup-stage:
|
||||
name: Setup - Stage
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cache-hit: ${{ steps.cache-pnpm-store.outputs.cache-hit }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
|
||||
- name: Setup Node.js with pnpm cache
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Cache pnpm store
|
||||
id: cache-pnpm-store
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
build-configs:
|
||||
name: Build - Configs
|
||||
needs: setup-stage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: |
|
||||
packages/rsbuild-config/dist
|
||||
packages/storybook-config/dist
|
||||
packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-www:
|
||||
name: Lint - WWW
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Lint WWW service
|
||||
run: pnpm --filter @ermnvldmr/www lint
|
||||
|
||||
typecheck-www:
|
||||
name: Typecheck - WWW
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Typecheck WWW service
|
||||
run: pnpm --filter @ermnvldmr/www typecheck
|
||||
|
||||
build-storybook:
|
||||
name: Build - Storybook
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Build WWW Storybook
|
||||
run: pnpm --filter @ermnvldmr/www build-storybook
|
||||
- name: Upload Storybook build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: storybook-static
|
||||
path: services/www/storybook-static/
|
||||
retention-days: 1
|
||||
|
||||
test-www:
|
||||
name: Test - Unit
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Test WWW service
|
||||
run: pnpm --filter @ermnvldmr/www test:coverage
|
||||
|
||||
test-storybook:
|
||||
name: Test - Storybook
|
||||
needs: build-storybook
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Install Playwright browsers
|
||||
run: pnpm --filter @ermnvldmr/www exec playwright install --with-deps chromium
|
||||
- name: Download Storybook build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: storybook-static
|
||||
path: services/www/storybook-static/
|
||||
- name: Serve Storybook and run tests
|
||||
run: pnpm --filter @ermnvldmr/www test:storybook:ci
|
||||
|
||||
build-www:
|
||||
name: Build - WWW Service
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.12.3
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Download Config Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Build WWW service
|
||||
run: pnpm --filter @ermnvldmr/www build
|
||||
|
||||
release-tag-docker:
|
||||
name: Release - Tag & Docker
|
||||
needs: [lint-www, typecheck-www, test-www, test-storybook, build-www]
|
||||
runs-on: ubuntu-latest
|
||||
environment: services-www-releases-publish
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
@@ -60,3 +281,26 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
deploy:
|
||||
name: Deploy - VPS
|
||||
needs: release-tag-docker
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: services-www-releases-deploy
|
||||
url: https://ermnvldmr.com
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_VPS_HOST }}
|
||||
username: ${{ secrets.DEPLOY_VPS_USER }}
|
||||
key: ${{ secrets.DEPLOY_VPS_SSH_KEY }}
|
||||
port: ${{ secrets.DEPLOY_VPS_SSH_PORT || 22 }}
|
||||
script: |
|
||||
cd /srv/ermnvldmr.com
|
||||
git pull
|
||||
chmod +x ci/scripts/deploy-new-tags.sh
|
||||
# Use the short SHA to match the tag from release-tag-docker
|
||||
export WWW_TAG="sha-$(echo ${{ github.sha }} | cut -c1-7)"
|
||||
./ci/scripts/deploy-new-tags.sh
|
||||
@@ -56,24 +56,24 @@ jobs:
|
||||
cache: 'pnpm'
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build Jest config
|
||||
run: pnpm --filter @ermnvldmr/jest-config build
|
||||
- name: Build Vite config
|
||||
run: pnpm --filter @ermnvldmr/vite-config build
|
||||
- name: Build Rsbuild config
|
||||
run: pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
- name: Build Storybook config
|
||||
run: pnpm --filter @ermnvldmr/storybook-config build
|
||||
- name: Build i18n
|
||||
run: pnpm --filter @ermnvldmr/i18n build
|
||||
- name: Upload Config Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: |
|
||||
packages/jest-config/dist
|
||||
packages/vite-config/dist
|
||||
packages/rsbuild-config/dist
|
||||
packages/storybook-config/dist
|
||||
packages/i18n/dist
|
||||
retention-days: 1
|
||||
|
||||
lint-www:
|
||||
name: Lint - WWW & Deps
|
||||
name: Lint - WWW
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -93,15 +93,11 @@ jobs:
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Lint STL package
|
||||
run: pnpm --filter @ermnvldmr/stl lint
|
||||
- name: Lint UI package
|
||||
run: pnpm --filter @ermnvldmr/ui lint
|
||||
- name: Lint WWW service
|
||||
run: pnpm --filter @ermnvldmr/www lint
|
||||
|
||||
typecheck-www:
|
||||
name: Typecheck - WWW & Deps
|
||||
name: Typecheck - WWW
|
||||
needs: build-configs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -121,10 +117,6 @@ jobs:
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Typecheck STL package
|
||||
run: pnpm --filter @ermnvldmr/stl typecheck
|
||||
- name: Typecheck UI package
|
||||
run: pnpm --filter @ermnvldmr/ui typecheck
|
||||
- name: Typecheck WWW service
|
||||
run: pnpm --filter @ermnvldmr/www typecheck
|
||||
|
||||
@@ -179,10 +171,6 @@ jobs:
|
||||
with:
|
||||
name: config-artifacts
|
||||
path: packages
|
||||
- name: Test STL package
|
||||
run: pnpm --filter @ermnvldmr/stl test:coverage
|
||||
- name: Test UI package
|
||||
run: pnpm --filter @ermnvldmr/ui test:coverage
|
||||
- name: Test WWW service
|
||||
run: pnpm --filter @ermnvldmr/www test:coverage
|
||||
|
||||
@@ -273,7 +261,7 @@ jobs:
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}/www
|
||||
tags: |
|
||||
type=raw,value=${{ steps.version.outputs.rc_version }}
|
||||
type=raw,value=v${{ steps.version.outputs.rc_version }}
|
||||
type=sha,format=short
|
||||
|
||||
- name: Build and push Docker image
|
||||
@@ -283,4 +271,4 @@ jobs:
|
||||
file: ci/docker/www/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
+5
-2
@@ -1,7 +1,6 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
*.tsbuildinfo
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
@@ -28,3 +27,7 @@ playwright-report/
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
||||
# agents plans and designs
|
||||
docs/plans/
|
||||
docs/superpowers/
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "services/docs/themes/hextra"]
|
||||
path = services/docs/themes/hextra
|
||||
url = https://github.com/imfing/hextra
|
||||
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
coverage
|
||||
storybook-static
|
||||
.next
|
||||
.astro
|
||||
public
|
||||
**/public/**
|
||||
**/dist/**
|
||||
**/node_modules/**
|
||||
pnpm-lock.yaml
|
||||
services/docs/themes/hextra
|
||||
+1
-2
@@ -4,6 +4,5 @@
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"useTabs": false,
|
||||
"plugins": ["prettier-plugin-astro", "prettier-plugin-react", "prettier-plugin-tailwindcss"]
|
||||
"useTabs": false
|
||||
}
|
||||
+146
-49
@@ -5,6 +5,7 @@
|
||||
**ermnvldmr.com** is Vladimir Eremin's personal ecosystem of services delivered through a modern monorepo architecture. The project encompasses personal websites, documentation sites, and shared infrastructure for web-based content and services.
|
||||
|
||||
### Monorepo Architecture
|
||||
|
||||
- **Repository Structure**: pnpm workspace monorepo
|
||||
- **Package Manager**: pnpm v9.12.3+
|
||||
- **Core Technology**: TypeScript (Strict mode)
|
||||
@@ -14,6 +15,7 @@
|
||||
- **CI/CD**: GitHub Actions with path-based triggers
|
||||
|
||||
### Design Philosophy
|
||||
|
||||
- **Component-First**: All UI elements developed as isolated, reusable components
|
||||
- **Configuration Sharing**: Centralized tooling configurations across services
|
||||
- **TypeScript-First**: Strict typing for all public APIs and components
|
||||
@@ -26,13 +28,12 @@
|
||||
```
|
||||
├── packages/ # Shared packages and configurations
|
||||
│ ├── eslint-config/ # Shared ESLint configurations
|
||||
│ ├── jest-config/ # Shared Jest configurations
|
||||
│ ├── rsbuild-config/ # Shared Rsbuild configurations
|
||||
│ ├── storybook-config/ # Shared Storybook configurations
|
||||
│ ├── vite-config/ # Shared Vite configurations
|
||||
│ ├── stl/ # Standard Type Library (utilities)
|
||||
│ └── ui/ # Shared React component library
|
||||
├── services/ # Independent web services
|
||||
│ ├── www/ # Main website (Astro + React)
|
||||
│ ├── www/ # Main website (Rsbuild + React)
|
||||
│ └── docs/ # Documentation site (Hugo)
|
||||
├── .github/workflows/ # CI/CD automation
|
||||
├── pnpm-workspace.yaml # Workspace configuration
|
||||
@@ -42,22 +43,27 @@
|
||||
### Package Organization
|
||||
|
||||
#### Configuration Packages (`packages/`)
|
||||
- **eslint-config**: Shared ESLint rules for TypeScript, React, and Astro
|
||||
- **jest-config**: Shared Jest configurations for different project types
|
||||
|
||||
- **eslint-config**: Shared ESLint rules for TypeScript and React
|
||||
- **rsbuild-config**: Shared Rsbuild and Rspack configurations for library and service builds
|
||||
- **storybook-config**: Shared Storybook setup and configurations
|
||||
- **vite-config**: Shared Vite configurations for library builds
|
||||
|
||||
#### Library Packages (`packages/`)
|
||||
|
||||
- **stl** (Standard Type Library): Common utilities, types, and helper functions
|
||||
- **ui**: React component library with design system and Storybook stories
|
||||
- **i18n**: Internationalization utilities
|
||||
|
||||
#### Service Packages (`services/`)
|
||||
- **www**: Main website built with Astro.js, React, and Tailwind CSS
|
||||
|
||||
- **www**: Main website built with Rsbuild, React, and Tailwind CSS
|
||||
- **static**: Static landing pages service built with Rsbuild and React
|
||||
- **docs**: Documentation site built with Hugo and Hextra theme
|
||||
|
||||
### Key Directory Rules
|
||||
|
||||
#### `/packages/`
|
||||
|
||||
- **Purpose**: Shared configurations and reusable libraries
|
||||
- **Dependencies**: Can depend on each other following build order (configs → stl → ui)
|
||||
- **Exports**: Must provide proper TypeScript declarations
|
||||
@@ -65,6 +71,7 @@
|
||||
- **Testing**: Individual test suites for each package
|
||||
|
||||
#### `/services/`
|
||||
|
||||
- **Purpose**: Independent web applications and sites
|
||||
- **Dependencies**: Can consume packages but not other services
|
||||
- **Deployment**: Independent deployment pipelines
|
||||
@@ -74,59 +81,93 @@
|
||||
## 🛠️ Development Workflow
|
||||
|
||||
### Monorepo Development Principles
|
||||
|
||||
- **Root-Level Scripts**: Check `package.json` at repository root for monorepo-wide operations
|
||||
- **Service-Specific Scripts**: Each service in `services/` has its own development commands
|
||||
- **Package Filtering**: Use pnpm workspace filtering to target specific packages/services
|
||||
- **Dependency-Aware Building**: UI components must be built before dependent services
|
||||
|
||||
### Package Development Workflow
|
||||
|
||||
The monorepo follows a strict dependency hierarchy that must be respected:
|
||||
|
||||
#### 1. Configuration Packages First
|
||||
|
||||
Configuration packages provide shared tooling setups and must be built before anything else. Look for packages ending in `-config` in the `packages/` directory.
|
||||
|
||||
#### 2. Library Packages
|
||||
|
||||
Library packages (`stl`, `ui`) provide reusable code and components. They depend on configuration packages and must be built in dependency order (check their `package.json` dependencies).
|
||||
|
||||
#### 3. Services
|
||||
|
||||
Services consume packages but are independent of each other. They can be developed and deployed separately. Check `services/` directory for available services and their individual `package.json` files for available scripts.
|
||||
|
||||
### Code Organization Principles
|
||||
|
||||
- **Dependency Order**: configs → stl → ui → services
|
||||
- **Single Responsibility**: Each package serves one clear purpose
|
||||
- **Explicit Dependencies**: All inter-package dependencies declared in package.json
|
||||
- **Build Isolation**: Each package can be built independently
|
||||
- **Configuration Inheritance**: Services inherit from shared configurations
|
||||
- **Package Entry Points**:
|
||||
- `.` (Main): Runtime code used in production (e.g., `import { ... } from '@ermnvldmr/stl'`).
|
||||
- `/testing`: Test-related utilities and mocks (e.g., `import { ... } from '@ermnvldmr/stl/testing'`).
|
||||
- `/dev`: Build-time dependencies (e.g., `import { ... } from '@ermnvldmr/stl/dev'`).
|
||||
- All other entry points are forbidden. Strictly build-time packages must still expose via `/dev`.
|
||||
- **Directory Structure (Packages & Services)**:
|
||||
- `src/lib/`: Helpers, utilities, and types.
|
||||
- `{category}/`: Grouped logic.
|
||||
- `index.ts`: Barrel export for all of `lib/`.
|
||||
- `testing.ts`: Testing utilities for the library.
|
||||
- `dev.ts`: Build-time utilities for the library.
|
||||
- `src/components/`: UI components.
|
||||
- `{ComponentName}/`: Isolated component directory.
|
||||
- `index.ts`: Barrel export for all components.
|
||||
- `testing.ts`: Component-specific testing utilities.
|
||||
- `src/static/`: Static assets (`.html`, `.css`, `.js`) directly in this directory (no subdirectories).
|
||||
- `src/app/` (Services only): Page definitions.
|
||||
- `{page}.tsx`: Single file per page, inlining the component in `createPage`.
|
||||
- `{path}/{page}.tsx`: Nested routing structure.
|
||||
- Top-level `src/index.ts`, `src/testing.ts`, and `src/dev.ts` should only re-export from `lib/` or `components/`.
|
||||
|
||||
## 📝 Coding Conventions
|
||||
|
||||
### Package Naming Convention
|
||||
|
||||
All packages use the `@ermnvldmr/` namespace:
|
||||
|
||||
- `@ermnvldmr/eslint-config` - Shared ESLint configurations
|
||||
- `@ermnvldmr/jest-config` - Shared Jest configurations
|
||||
- `@ermnvldmr/rsbuild-config` - Shared Rsbuild configurations
|
||||
- `@ermnvldmr/storybook-config` - Shared Storybook configurations
|
||||
- `@ermnvldmr/vite-config` - Shared Vite configurations
|
||||
- `@ermnvldmr/stl` - Standard Type Library
|
||||
- `@ermnvldmr/ui` - Component library
|
||||
- `@ermnvldmr/i18n` - Internationalization utilities
|
||||
- `@ermnvldmr/www` - Main website service
|
||||
- `@ermnvldmr/static` - Static landing pages service
|
||||
- `@ermnvldmr/docs` - Documentation service
|
||||
|
||||
### TypeScript Guidelines
|
||||
|
||||
- **Strict Mode**: All TypeScript strict checks must pass
|
||||
- **Explicit Types**: Avoid `any` and prefer explicit type definitions
|
||||
- **Interface First**: Use interfaces for component props and public APIs
|
||||
- **Cross-Package Types**: Export types from packages for service consumption
|
||||
- **No 'any'**: The use of `any` is strictly prohibited. Use `unknown` if the type is truly unknown, or define proper generic constraints.
|
||||
- **No 'ts-ignore' or 'ts-expect-error'**: Never suppress compiler errors. If a third-party library has missing types, provide a local declaration file (.d.ts) instead.
|
||||
- **No Unsafe Assertions**: Avoid `as any` or `as T` assertions. Prefer type guards, `instanceof` checks, or proper schema validation (e.g., Zod) to narrow types.
|
||||
- **Explicit Types**: Avoid type inference for public APIs; prefer explicit type definitions for exports.
|
||||
- **Interface First**: Use interfaces for component props and public APIs.
|
||||
- **Cross-Package Types**: Export types from packages for service consumption.
|
||||
|
||||
### TSDoc Documentation Requirements
|
||||
|
||||
**MANDATORY for all exported elements**: types, functions, constants, components
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Combines class names using clsx and tailwind-merge for consistent styling.
|
||||
*
|
||||
*
|
||||
* @param inputs - Class name inputs to combine
|
||||
* @returns Combined and deduplicated class string
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* \`\`\`typescript
|
||||
* cn('px-4 py-2', 'bg-blue-500', 'px-6') // 'py-2 bg-blue-500 px-6'
|
||||
@@ -138,8 +179,19 @@ export function cn(...inputs: ClassValue[]): string {
|
||||
}
|
||||
```
|
||||
|
||||
### Internationalization (i18n) Guidelines
|
||||
|
||||
- **Package**: Use the `@ermnvldmr/i18n` package for all translations across services.
|
||||
- **Keyset Pattern**: Create `.i18n.ts` files alongside components that require translation.
|
||||
- **Russian Translation Style**: Embrace the Russian language fully. Prefer phonetic transliteration/transcription for global brand names and descriptive terms over keeping them in English.
|
||||
- **Brands**: 'GitHub' → 'ГитХаб', 'Instagram' → 'Инстаграм', 'Telegram' → 'Телеграм'.
|
||||
- **Descriptive**: 'Email' → 'Электропочта'.
|
||||
- **Source of Truth**: The English locale (`en`) serves as the primary reference for keys and structure.
|
||||
|
||||
### Component Development Patterns
|
||||
|
||||
#### UI Package Components
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Props for the Button component.
|
||||
@@ -170,59 +222,83 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
```
|
||||
|
||||
### File Naming Conventions
|
||||
|
||||
- **Components**: PascalCase (e.g., `Button.tsx`, `NavigationMenu.tsx`)
|
||||
- **Utilities**: camelCase (e.g., `cn.ts`, `formatters.ts`)
|
||||
- **Constants**: UPPER_SNAKE_CASE for exports (e.g., `API_ENDPOINTS`)
|
||||
- **Types**: PascalCase with descriptive suffixes (e.g., `ButtonProps`, `ApiResponse`)
|
||||
- **Config Files**: kebab-case (e.g., `jest-config`, `eslint-config`)
|
||||
- **Config Files**: kebab-case (e.g., `rsbuild-config`, `eslint-config`)
|
||||
|
||||
### Module Structure Patterns
|
||||
|
||||
#### Package Structure
|
||||
|
||||
```
|
||||
packages/ui/
|
||||
├── .config/
|
||||
│ ├── vitest/
|
||||
│ │ └── setup.ts
|
||||
│ └── storybook/
|
||||
│ ├── main.ts
|
||||
│ └── preview.tsx
|
||||
├── src/
|
||||
│ ├── components/ # Component categories
|
||||
│ │ ├── generic/ # Layout and generic components
|
||||
│ │ └── paper/ # Themed component variants
|
||||
│ ├── styles/ # Global styles and CSS
|
||||
│ ├── test-utils.tsx # Testing utilities
|
||||
│ └── index.ts # Main package exports
|
||||
│ │ ├── index.ts # Production barrel
|
||||
│ │ ├── testing.ts # Testing barrel
|
||||
│ │ └── Button/ # Isolated component
|
||||
│ ├── lib/ # Core logic
|
||||
│ │ ├── index.ts
|
||||
│ │ ├── testing.ts
|
||||
│ │ └── dev.ts
|
||||
│ ├── static/ # Flat static assets (CSS, etc.)
|
||||
│ ├── index.ts # Main entry point (runtime)
|
||||
│ ├── testing.ts # Testing entry point
|
||||
│ └── dev.ts # Dev entry point
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
└── .storybook/ # Storybook configuration
|
||||
├── rsbuild.config.ts
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
#### Service Structure (www example)
|
||||
|
||||
```
|
||||
services/www/
|
||||
├── .config/
|
||||
│ ├── vitest/
|
||||
│ │ └── setup.ts
|
||||
│ └── storybook/
|
||||
├── src/
|
||||
│ ├── layouts/ # Astro layout components
|
||||
│ ├── pages/ # Astro pages (file-based routing)
|
||||
│ ├── shared/ # Shared utilities and constants
|
||||
│ └── global.css # Service-specific styles
|
||||
├── public/ # Static assets
|
||||
├── astro.config.mjs # Astro configuration
|
||||
├── package.json
|
||||
└── tailwind.config.mjs # Tailwind configuration
|
||||
│ ├── app/ # Single file per page
|
||||
│ │ ├── index.tsx # Inlined createPage
|
||||
│ │ └── articles/
|
||||
│ ├── components/ # layouts, widgets
|
||||
│ ├── lib/ # core, shared
|
||||
│ ├── static/ # global.css, index.html
|
||||
│ ├── index.ts
|
||||
│ └── ...
|
||||
├── public/ # Direct static assets (images)
|
||||
├── rsbuild.config.ts
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 🎨 Design System (UI Package)
|
||||
|
||||
### Component Organization
|
||||
|
||||
- **Generic Components**: Layout utilities (Stack, VStack, HStack)
|
||||
- **Themed Components**: Paper-themed variants with consistent styling
|
||||
- **Radix Integration**: Accessibility-first components using Radix UI
|
||||
- **Storybook Stories**: Every component has comprehensive documentation
|
||||
|
||||
### Styling Approach
|
||||
|
||||
- **Tailwind CSS v4+**: Utility-first CSS framework
|
||||
- **Class Variance Authority**: Type-safe variant generation
|
||||
- **Custom Design Tokens**: Consistent spacing, colors, typography
|
||||
- **Responsive Design**: Mobile-first responsive patterns
|
||||
|
||||
### Component Categories
|
||||
|
||||
```typescript
|
||||
// Generic Layout Components
|
||||
export { Stack } from './components/generic/Stack/Stack';
|
||||
@@ -237,49 +313,56 @@ export { Separator } from './components/paper/PaperSeparator/Separator';
|
||||
## ⚙️ Configuration Management
|
||||
|
||||
### Shared Configurations
|
||||
|
||||
Each configuration package provides standardized setups:
|
||||
|
||||
#### ESLint Configuration
|
||||
|
||||
```javascript
|
||||
// Provides: base, react, astro configurations
|
||||
import { base } from '@ermnvldmr/eslint-config';
|
||||
import { react } from '@ermnvldmr/eslint-config/react';
|
||||
import { astro } from '@ermnvldmr/eslint-config/astro';
|
||||
```
|
||||
|
||||
#### Jest Configuration
|
||||
```typescript
|
||||
// Provides: base, react configurations
|
||||
import { base } from '@ermnvldmr/jest-config/base';
|
||||
import { react } from '@ermnvldmr/jest-config/react';
|
||||
import { baseConfig } from '@ermnvldmr/eslint-config/dev';
|
||||
```
|
||||
|
||||
#### Vite Configuration
|
||||
#### Vitest Configuration
|
||||
|
||||
```typescript
|
||||
// Provides: library build configurations
|
||||
import { defineLibConfig } from '@ermnvldmr/vite-config/lib';
|
||||
// Provides: base, react configurations from rsbuild-config
|
||||
import { baseVitestConfig, reactVitestConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
import { mergeConfig } from 'vitest/config';
|
||||
```
|
||||
|
||||
#### Rsbuild Configuration
|
||||
|
||||
```typescript
|
||||
// Provides: definePackageConfig, defineServiceConfig
|
||||
import { definePackageConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
```
|
||||
|
||||
### Service Configuration Inheritance
|
||||
|
||||
Services inherit from shared configs but can override:
|
||||
|
||||
```typescript
|
||||
// In service package.json devDependencies
|
||||
"@ermnvldmr/eslint-config": "workspace:*"
|
||||
"@ermnvldmr/jest-config": "workspace:*"
|
||||
"@ermnvldmr/rsbuild-config": "workspace:*"
|
||||
```
|
||||
|
||||
## 🚀 Build & Deployment
|
||||
|
||||
### Build Dependencies
|
||||
|
||||
The monorepo must respect a strict build order due to package dependencies:
|
||||
|
||||
1. **Configuration packages** (eslint-config, jest-config, etc.) - Foundation tooling
|
||||
1. **Configuration packages** (eslint-config, rsbuild-config, etc.) - Foundation tooling
|
||||
2. **STL package** - Shared utilities and types
|
||||
3. **UI package** - Component library (depends on STL)
|
||||
4. **Services** - Applications that consume packages (www, docs)
|
||||
|
||||
### CI/CD Pipeline Structure
|
||||
|
||||
The repository uses path-based CI/CD triggers - each package and service has dedicated workflows in `.github/workflows/`. Examine the workflow files to understand:
|
||||
|
||||
- **Trigger Patterns**: Which file changes activate each pipeline
|
||||
- **Build Dependencies**: How packages are built in sequence
|
||||
- **Quality Gates**: Linting, type-checking, and testing requirements
|
||||
@@ -288,12 +371,14 @@ The repository uses path-based CI/CD triggers - each package and service has ded
|
||||
## 🔧 Development Environment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js**: v20+
|
||||
- **pnpm**: v9.12.3+
|
||||
- **TypeScript**: v5.8+
|
||||
- **Hugo**: v0.110.0+ (for docs service)
|
||||
|
||||
### Initial Setup
|
||||
|
||||
To start development, follow the dependency hierarchy:
|
||||
|
||||
1. **Install dependencies** using the root `package.json` scripts
|
||||
@@ -302,6 +387,7 @@ To start development, follow the dependency hierarchy:
|
||||
4. **Start services** after dependencies are built
|
||||
|
||||
### Development Commands
|
||||
|
||||
The monorepo provides scripts at multiple levels:
|
||||
|
||||
- **Root Level**: Check root `package.json` for monorepo-wide operations
|
||||
@@ -313,13 +399,15 @@ Use pnpm workspace filtering (`--filter`) to target specific packages or service
|
||||
## 🧪 Testing Strategy
|
||||
|
||||
### Testing Framework Distribution
|
||||
- **Shared Configuration**: Jest configs in `@ermnvldmr/jest-config`
|
||||
|
||||
- **Shared Configuration**: Vitest configs in `@ermnvldmr/rsbuild-config`
|
||||
- **Package Testing**: Each package has its own test suite
|
||||
- **Service Testing**: Services test their specific functionality
|
||||
- **Component Testing**: UI package uses React Testing Library
|
||||
- **Component Testing**: UI package uses React Testing Library with Vitest
|
||||
- **Storybook Testing**: Visual regression and interaction testing
|
||||
|
||||
### Test Organization
|
||||
|
||||
```typescript
|
||||
// Package-level testing (packages/stl/src/functions/cn/cn.test.ts)
|
||||
describe('cn function', () => {
|
||||
@@ -338,10 +426,19 @@ describe('components/paper/Button/Button', () => {
|
||||
```
|
||||
|
||||
### Coverage Strategy
|
||||
|
||||
- **Package Coverage**: Individual coverage reports per package
|
||||
- **Service Coverage**: Service-specific coverage including Storybook tests
|
||||
- **Aggregated Coverage**: Combined reporting for the entire monorepo
|
||||
|
||||
---
|
||||
|
||||
*This guide serves as the architectural foundation for the monorepo. Focus on these stable patterns and structures rather than specific implementation details that may change over time.*
|
||||
_This guide serves as the architectural foundation for the monorepo. Focus on these stable patterns and structures rather than specific implementation details that may change over time._
|
||||
|
||||
## 🤖 AI Agent Best Practices
|
||||
|
||||
The following are explicit best practices and preferences intended for AI tools and agents operating within this repository:
|
||||
|
||||
- **Build System Preference**: Rsbuild and Rspack are the standard and strongly preferred build tools for all projects in this monorepo due to their Rust-based architecture and performance. Do not introduce Vite, Astro, or Webpack unless strictly necessary.
|
||||
- **Framework Preference**: The monorepo has migrated away from Astro in favor of React + Rsbuild (with MDX support).
|
||||
- **Content Sites**: Rspress is the preferred infrastructure for any new content-heavy or documentation sites.
|
||||
@@ -23,16 +23,17 @@ Composite (see [Copyright & License](#copyright--license))
|
||||
|
||||
#### Services
|
||||
|
||||
- **[www](./services/www)**: Main website, built with Astro and React.
|
||||
- **[www](./services/www)**: Main website, built with Rsbuild, React, and MDX.
|
||||
- **[static](./services/static)**: Static landing pages, built with Rsbuild and React.
|
||||
- **[docs](./services/docs)**: Documentation site, built with Hugo and the Hextra theme.
|
||||
|
||||
#### Packages
|
||||
|
||||
- **[@ermnvldmr/ui](./packages/ui)**: Shared React component library and design system.
|
||||
- **[@ermnvldmr/stl](./packages/stl)**: Standard Template Library – common utilities and reusable logic.
|
||||
- **[@ermnvldmr/i18n](./packages/i18n)**: Internationalization utilities.
|
||||
- **[@ermnvldmr/eslint-config](./packages/eslint-config)**: Shared linting configurations.
|
||||
- **[@ermnvldmr/jest-config](./packages/jest-config)**: Shared testing configurations.
|
||||
- **[@ermnvldmr/vite-config](./packages/vite-config)**: Shared build configurations.
|
||||
- **[@ermnvldmr/rsbuild-config](./packages/rsbuild-config)**: Shared build configurations (Rsbuild & Rspack).
|
||||
- **[@ermnvldmr/storybook-config](./packages/storybook-config)**: Shared Storybook configurations.
|
||||
|
||||
## Who Are You?
|
||||
@@ -49,9 +50,9 @@ Want to contribute or understand the codebase.
|
||||
|
||||
- **Setup**: `pnpm install`
|
||||
- **Development**:
|
||||
- Website: `pnpm www:dev`
|
||||
- Documentation: `pnpm docs:dev`
|
||||
- UI Library: `pnpm ui:dev`
|
||||
- Website: `pnpm www:dev`
|
||||
- Documentation: `pnpm docs:dev`
|
||||
- UI Library: `pnpm ui:dev`
|
||||
- **Development Guide**: [AGENTS.md](./AGENTS.md)
|
||||
- **Component Development**: `pnpm ui:storybook`
|
||||
- **Testing**: `pnpm test` or `pnpm test:coverage` (View results on [Codecov](https://app.codecov.io/github/deytenit/ermnvldmr.com))
|
||||
@@ -63,7 +64,7 @@ Interested in the design system and components.
|
||||
|
||||
- **Components**: Run `pnpm ui:storybook` to explore components
|
||||
- **Design System**: See [packages/ui/src/styles/index.css](./packages/ui/src/styles/index.css)
|
||||
for the color palette and theme
|
||||
for the color palette and theme
|
||||
- **Theme Structure**: CSS variables with light/dark mode support
|
||||
|
||||
### Contributor
|
||||
@@ -87,12 +88,12 @@ Want to submit improvements.
|
||||
|
||||
**Documentation & Informational Content**: Technical documentation, guides, and informational articles within `services/docs/content/` and `services/www/content/` are licensed under [Creative Commons Attribution 4.0 International (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/).
|
||||
|
||||
**Creative Literature & Art**: Poems, personal essays, artworks, photography, and other purely creative or literary works are **individually copyrighted by their respective owners**. These works are *not* open-source and are *not* covered by the GPL or CC BY 4.0 licenses. They remain under full copyright protection unless explicitly stated otherwise within their specific file metadata or directory.
|
||||
**Creative Literature & Art**: Poems, personal essays, artworks, photography, and other purely creative or literary works are **individually copyrighted by their respective owners**. These works are _not_ open-source and are _not_ covered by the GPL or CC BY 4.0 licenses. They remain under full copyright protection unless explicitly stated otherwise within their specific file metadata or directory.
|
||||
|
||||
---
|
||||
|
||||
**AI Assistance**:
|
||||
Significant portions of this project's *source code* were developed with
|
||||
Significant portions of this project's _source code_ were developed with
|
||||
the assistance of AI code generation tools.
|
||||
|
||||
Copyright © 2026 Vladimir Eremin
|
||||
@@ -12,20 +12,38 @@ server {
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
# Clean URLs (remove .html from URI)
|
||||
location / {
|
||||
try_files $uri $uri/ $uri.html /index.html;
|
||||
# Root redirect: detect browser language, fallback to /en/
|
||||
location = / {
|
||||
set $locale "en";
|
||||
if ($http_accept_language ~* "(^|,\s*)ru") {
|
||||
set $locale "ru";
|
||||
}
|
||||
return 302 /$locale/;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# Cache hashed static assets (JS, CSS, fonts) — matched before locale prefix blocks
|
||||
# root is inherited from server block, so /en/static/js/file.js resolves correctly
|
||||
location ~* \.(js|css|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache images
|
||||
location ~* \.(png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
internal;
|
||||
# English locale — SPA fallback for non-asset routes
|
||||
location /en/ {
|
||||
try_files $uri $uri/ $uri.html /en/index.html;
|
||||
}
|
||||
|
||||
# Russian locale — SPA fallback for non-asset routes
|
||||
location /ru/ {
|
||||
try_files $uri $uri/ $uri.html /ru/index.html;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /en/index.html;
|
||||
}
|
||||
@@ -6,8 +6,8 @@ services:
|
||||
container_name: com-ermnvldmr-nginx
|
||||
restart: always
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
@@ -37,6 +37,20 @@ services:
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
www:
|
||||
image: ghcr.io/deytenit/ermnvldmr.com/www:${WWW_TAG}
|
||||
container_name: com-ermnvldmr-www
|
||||
restart: always
|
||||
networks:
|
||||
- com-ermnvldmr-enclave
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.25'
|
||||
memory: 128M
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
networks:
|
||||
com-ermnvldmr-direct:
|
||||
name: com-ermnvldmr-direct
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name www.ermnvldmr.com;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name www.ermnvldmr.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/www.ermnvldmr.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/www.ermnvldmr.com/privkey.pem;
|
||||
|
||||
# Optimized SSL settings
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:50m;
|
||||
ssl_session_tickets off;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# HSTS
|
||||
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
|
||||
location / {
|
||||
proxy_pass http://com-ermnvldmr-www:80;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,14 @@ WORKDIR /app
|
||||
|
||||
# Copy monorepo configuration
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
|
||||
# Copy all package.json files to allow pnpm to fetch dependencies
|
||||
COPY packages/eslint-config/package.json ./packages/eslint-config/
|
||||
COPY packages/jest-config/package.json ./packages/jest-config/
|
||||
COPY packages/rsbuild-config/package.json ./packages/rsbuild-config/
|
||||
COPY packages/stl/package.json ./packages/stl/
|
||||
COPY packages/storybook-config/package.json ./packages/storybook-config/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/vite-config/package.json ./packages/vite-config/
|
||||
COPY packages/i18n/package.json ./packages/i18n/
|
||||
COPY services/www/package.json ./services/www/
|
||||
|
||||
# Install dependencies
|
||||
@@ -24,8 +25,14 @@ RUN pnpm install --frozen-lockfile
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the website
|
||||
RUN pnpm --filter @ermnvldmr/www build
|
||||
# Build dependencies
|
||||
RUN pnpm --filter @ermnvldmr/rsbuild-config build
|
||||
RUN pnpm --filter @ermnvldmr/stl build
|
||||
RUN pnpm --filter @ermnvldmr/i18n build
|
||||
RUN pnpm --filter @ermnvldmr/ui build
|
||||
|
||||
# Build the website (all locales)
|
||||
RUN pnpm --filter @ermnvldmr/www build:all
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM nginx:alpine
|
||||
@@ -34,4 +41,4 @@ COPY --from=builder /app/services/www/dist /usr/share/nginx/html
|
||||
COPY ci/config/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -47,16 +47,17 @@ case $MODE in
|
||||
if [ "$MODE" == "release" ]; then
|
||||
echo "$VERSION_TAG"
|
||||
else
|
||||
# Find the latest RC for THIS specific version
|
||||
RC_PREFIX="$TAG_PREFIX.$VERSION_TAG-rc"
|
||||
LATEST_RC_TAG=$(git tag -l "$RC_PREFIX.*" | sort -V | tail -n1 || echo "")
|
||||
|
||||
if [ -z "$LATEST_RC_TAG" ]; then
|
||||
RC=1
|
||||
# Calculate RC based on commit count since last tag
|
||||
# This ensures unique, increasing RC numbers without needing to tag every RC
|
||||
LAST_SERVICE_TAG=$(git describe --tags --match "releases/$SERVICE_NAME/*" --abbrev=0 HEAD 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$LAST_SERVICE_TAG" ]; then
|
||||
COMMITS_COUNT=$(git rev-list --count HEAD)
|
||||
else
|
||||
LATEST_RC=${LATEST_RC_TAG#"$RC_PREFIX."}
|
||||
RC=$((LATEST_RC + 1))
|
||||
COMMITS_COUNT=$(git rev-list --count "$LAST_SERVICE_TAG"..HEAD)
|
||||
fi
|
||||
|
||||
RC=$((COMMITS_COUNT + 1))
|
||||
echo "$VERSION_TAG-rc.$RC"
|
||||
fi
|
||||
;;
|
||||
|
||||
+12
-6
@@ -2,30 +2,36 @@ codecov:
|
||||
notify:
|
||||
wait_for_ci: true
|
||||
|
||||
ignore:
|
||||
- 'packages/eslint-config'
|
||||
- 'packages/rsbuild-config'
|
||||
- 'packages/storybook-config'
|
||||
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
target: 80%
|
||||
threshold: 1%
|
||||
range: 10%
|
||||
stl:
|
||||
target: auto
|
||||
target: 80%
|
||||
flags:
|
||||
- stl
|
||||
ui:
|
||||
target: auto
|
||||
target: 80%
|
||||
flags:
|
||||
- ui
|
||||
ui-storybook:
|
||||
target: auto
|
||||
target: 80%
|
||||
flags:
|
||||
- ui-storybook
|
||||
www:
|
||||
target: auto
|
||||
target: 80%
|
||||
flags:
|
||||
- www
|
||||
www-storybook:
|
||||
target: auto
|
||||
target: 80%
|
||||
flags:
|
||||
- www-storybook
|
||||
|
||||
|
||||
+12
-15
@@ -1,6 +1,5 @@
|
||||
// @ts-check
|
||||
import { baseConfig } from '@ermnvldmr/eslint-config';
|
||||
import eslintPluginAstro from 'eslint-plugin-astro';
|
||||
import { baseConfig } from '@ermnvldmr/eslint-config/dev';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
@@ -9,25 +8,23 @@ import jsxA11y from 'eslint-plugin-jsx-a11y';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
export default [
|
||||
...baseConfig,
|
||||
|
||||
// Additional ignores for monorepo
|
||||
{
|
||||
ignores: [
|
||||
'services/docs/**/*', // Hugo content, no linting needed
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/coverage/**',
|
||||
'**/storybook-static/**',
|
||||
'**/.next/**',
|
||||
'**/*.d.ts',
|
||||
],
|
||||
},
|
||||
|
||||
// Astro
|
||||
...eslintPluginAstro.configs.recommended,
|
||||
{
|
||||
files: ['services/www/**/*.astro', 'services/static/**/*.astro'],
|
||||
rules: {
|
||||
// Astro specific rules can go here
|
||||
},
|
||||
},
|
||||
|
||||
// Storybook - recommended configuration for flat config
|
||||
...storybook.configs['flat/recommended'],
|
||||
|
||||
@@ -97,7 +94,7 @@ export default tseslint.config(
|
||||
// React Refresh
|
||||
{
|
||||
files: ['**/*.{jsx,tsx}'],
|
||||
ignores: ['.config/jest/**/*.{jsx,tsx}'],
|
||||
ignores: ['.config/jest/**/*.{jsx,tsx}', '**/test-utils.tsx'],
|
||||
plugins: { 'react-refresh': reactRefresh },
|
||||
rules: {
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
@@ -136,5 +133,5 @@ export default tseslint.config(
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
];
|
||||
+2
-1
@@ -43,7 +43,8 @@
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"test": "pnpm -r test",
|
||||
"test:coverage": "pnpm -r test:coverage",
|
||||
"clean": "pnpm -r clean && rm -rf node_modules"
|
||||
"clean": "pnpm -r clean && rm -rf node_modules",
|
||||
"build:deps": "pnpm -r --filter='./packages/*-config' build && pnpm --filter='./packages/i18n' build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"globals": "^16.0.0",
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier/flat';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import jsdoc from 'eslint-plugin-jsdoc';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
/**
|
||||
* Base ESLint configuration
|
||||
*/
|
||||
export const baseConfig = tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
'**/dist',
|
||||
'**/node_modules',
|
||||
'**/.github',
|
||||
'**/.changeset',
|
||||
'**/coverage',
|
||||
'.config/**/*',
|
||||
'*.config.{js,ts,mjs,cjs}',
|
||||
'!.storybook', // Allow linting of .storybook directory
|
||||
],
|
||||
},
|
||||
|
||||
// JavaScript
|
||||
eslint.configs.recommended,
|
||||
|
||||
// TypeScript - Strict Configuration (only for .ts/.tsx files)
|
||||
{
|
||||
files: ['**/*.{ts,tsx,mts,cts}'],
|
||||
extends: [
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// TypeScript strict rules
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'error',
|
||||
'@typescript-eslint/prefer-optional-chain': 'error',
|
||||
'@typescript-eslint/no-unnecessary-condition': 'error',
|
||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||
'@typescript-eslint/prefer-as-const': 'error',
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', disallowTypeAnnotations: false },
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
'error',
|
||||
{ allowExpressions: true, allowTypedFunctionExpressions: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Allow triple-slash references in `*.d.ts` files.
|
||||
{
|
||||
files: ['**/*.d.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/triple-slash-reference': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Import/Export Rules
|
||||
{
|
||||
plugins: { import: importPlugin },
|
||||
rules: {
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
groups: [
|
||||
'builtin',
|
||||
'external',
|
||||
'internal',
|
||||
['parent', 'sibling'],
|
||||
'index',
|
||||
'type',
|
||||
],
|
||||
'newlines-between': 'always',
|
||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||
},
|
||||
],
|
||||
'import/no-duplicates': 'error',
|
||||
'import/no-unused-modules': 'error',
|
||||
'import/no-cycle': 'error',
|
||||
'import/no-self-import': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// JSDoc Requirements - Only for main source code
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}', 'packages/**/*.{ts,tsx}', 'services/**/*.{ts,tsx}'],
|
||||
plugins: { jsdoc },
|
||||
rules: {
|
||||
'jsdoc/require-jsdoc': [
|
||||
'error',
|
||||
{
|
||||
contexts: [
|
||||
'ExportNamedDeclaration > FunctionDeclaration',
|
||||
'ExportDefaultDeclaration > FunctionDeclaration',
|
||||
'TSInterfaceDeclaration',
|
||||
'TSTypeAliasDeclaration',
|
||||
],
|
||||
},
|
||||
],
|
||||
'jsdoc/require-description': 'error',
|
||||
'jsdoc/require-param': 'error',
|
||||
'jsdoc/require-returns': 'error',
|
||||
'jsdoc/require-example': 'warn',
|
||||
'jsdoc/check-alignment': 'error',
|
||||
'jsdoc/check-indentation': 'error',
|
||||
'jsdoc/check-syntax': 'error',
|
||||
'jsdoc/check-tag-names': 'error',
|
||||
'jsdoc/check-types': 'error',
|
||||
'jsdoc/valid-types': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// Test files
|
||||
{
|
||||
files: ['**/*.{test,spec}.{js,jsx,ts,tsx}', '.config/jest/**/*.{js,jsx,ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.jest,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Relax rules for test files and jest configuration
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'jsdoc/require-jsdoc': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// CommonJS files - disable all TypeScript rules
|
||||
{
|
||||
files: ['**/*.cjs'],
|
||||
...tseslint.configs.disableTypeChecked,
|
||||
},
|
||||
|
||||
// Config files and tooling
|
||||
{
|
||||
files: ['*.config.{js,ts,mjs,cjs}', '.storybook/**/*.{js,ts,cjs,mjs}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'jsdoc/require-jsdoc': 'off',
|
||||
'no-undef': 'off', // For CommonJS files
|
||||
},
|
||||
},
|
||||
|
||||
// Prettier
|
||||
prettier
|
||||
);
|
||||
@@ -3,16 +3,16 @@
|
||||
"version": "1.0.0",
|
||||
"description": "Shared ESLint configuration",
|
||||
"type": "module",
|
||||
"main": "./index.js",
|
||||
"exports": {
|
||||
"./dev": "./src/dev.js",
|
||||
"./testing": "./src/testing.js"
|
||||
},
|
||||
"files": [
|
||||
"index.js",
|
||||
"react.js",
|
||||
"astro.js"
|
||||
"src"
|
||||
],
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-astro": "^1.3.1",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsdoc": "^61.5.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
@@ -20,7 +20,9 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"eslint-plugin-storybook": "^0.10.2",
|
||||
"globals": "^16.0.0",
|
||||
"typescript-eslint": "^8.31.1"
|
||||
"globals": "^16.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript-eslint": "^8.53.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/dev.js';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/index.js';
|
||||
@@ -0,0 +1,25 @@
|
||||
// @ts-check
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
|
||||
/**
|
||||
* Import and Export rules.
|
||||
*/
|
||||
export const importsConfig = [
|
||||
{
|
||||
plugins: { import: importPlugin },
|
||||
rules: {
|
||||
'import/order': [
|
||||
'error',
|
||||
{
|
||||
groups: ['builtin', 'external', 'internal', ['parent', 'sibling'], 'index', 'type'],
|
||||
'newlines-between': 'always',
|
||||
alphabetize: { order: 'asc', caseInsensitive: true },
|
||||
},
|
||||
],
|
||||
'import/no-duplicates': 'error',
|
||||
'import/no-unused-modules': 'error',
|
||||
'import/no-cycle': 'error',
|
||||
'import/no-self-import': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,43 @@
|
||||
// @ts-check
|
||||
import { javascriptConfig } from './javascript.js';
|
||||
import { getTypescriptConfig } from './typescript.js';
|
||||
import { importsConfig } from './imports.js';
|
||||
import { jsdocConfig } from './jsdoc.js';
|
||||
import { testingConfig } from './testing.js';
|
||||
import { toolingConfig } from './tooling.js';
|
||||
import { defineMonorepoRestrictedImports } from './monorepo.js';
|
||||
|
||||
/**
|
||||
* Common ignores for the entire monorepo.
|
||||
*/
|
||||
const ignoresConfig = {
|
||||
ignores: [
|
||||
'**/dist',
|
||||
'**/node_modules',
|
||||
'**/.github',
|
||||
'**/.changeset',
|
||||
'**/coverage',
|
||||
'.config/**/*',
|
||||
'*.config.{js,ts,mjs,cjs}',
|
||||
'!.storybook', // Allow linting of .storybook directory
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Assemble the base ESLint configuration.
|
||||
*
|
||||
* @param {string} rootDir - The root directory of the project using this config.
|
||||
* @returns {import('eslint').Linter.Config[]} The assembled configuration.
|
||||
*/
|
||||
export function defineBaseConfig(rootDir) {
|
||||
return [
|
||||
ignoresConfig,
|
||||
...javascriptConfig,
|
||||
...getTypescriptConfig(rootDir),
|
||||
...importsConfig,
|
||||
...jsdocConfig,
|
||||
...testingConfig,
|
||||
...toolingConfig,
|
||||
...defineMonorepoRestrictedImports(rootDir),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier/flat';
|
||||
import globals from 'globals';
|
||||
|
||||
/**
|
||||
* Core JavaScript and Prettier configuration.
|
||||
*/
|
||||
export const javascriptConfig = [
|
||||
// JavaScript recommended rules
|
||||
eslint.configs.recommended,
|
||||
|
||||
// Browser and Service Worker globals
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.serviceworker,
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Prettier integration (must be last in the assembler)
|
||||
prettier,
|
||||
];
|
||||
@@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
import jsdoc from 'eslint-plugin-jsdoc';
|
||||
|
||||
/**
|
||||
* JSDoc documentation requirements.
|
||||
*/
|
||||
export const jsdocConfig = [
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}', 'packages/**/*.{ts,tsx}', 'services/**/*.{ts,tsx}'],
|
||||
plugins: { jsdoc },
|
||||
rules: {
|
||||
'jsdoc/require-jsdoc': [
|
||||
'error',
|
||||
{
|
||||
contexts: [
|
||||
'ExportNamedDeclaration > FunctionDeclaration',
|
||||
'ExportDefaultDeclaration > FunctionDeclaration',
|
||||
'TSInterfaceDeclaration',
|
||||
'TSTypeAliasDeclaration',
|
||||
],
|
||||
},
|
||||
],
|
||||
'jsdoc/require-description': 'error',
|
||||
'jsdoc/require-param': 'off',
|
||||
'jsdoc/require-returns': 'off',
|
||||
'jsdoc/require-example': 'warn',
|
||||
'jsdoc/check-alignment': 'error',
|
||||
'jsdoc/check-indentation': 'error',
|
||||
'jsdoc/check-syntax': 'error',
|
||||
'jsdoc/check-tag-names': 'error',
|
||||
'jsdoc/check-types': 'error',
|
||||
'jsdoc/valid-types': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,70 @@
|
||||
// @ts-check
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
/** @type {import('eslint').Linter.Config[] | null} */
|
||||
let cachedRules = null;
|
||||
|
||||
/**
|
||||
* Dynamically generates restricted import rules for monorepo packages
|
||||
* to ensure they use relative imports for internal code.
|
||||
*
|
||||
* @param {string} rootDir - The root directory of the monorepo.
|
||||
* @returns {import('eslint').Linter.Config[]} An array of ESLint configurations.
|
||||
*/
|
||||
export function defineMonorepoRestrictedImports(rootDir) {
|
||||
if (cachedRules) return cachedRules;
|
||||
|
||||
const workspaces = ['packages', 'services'];
|
||||
const configs = [];
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
const workspacePath = path.join(rootDir, workspace);
|
||||
if (!fs.existsSync(workspacePath)) continue;
|
||||
|
||||
const entries = fs.readdirSync(workspacePath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const pkgPath = path.join(workspacePath, entry.name);
|
||||
const pkgJsonPath = path.join(pkgPath, 'package.json');
|
||||
|
||||
if (fs.existsSync(pkgJsonPath)) {
|
||||
try {
|
||||
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
||||
const pkgName = pkgJson.name;
|
||||
// Use relative path from root to package for 'files' pattern
|
||||
const relativePkgPath = path.relative(rootDir, pkgPath);
|
||||
|
||||
configs.push({
|
||||
files: [`${relativePkgPath}/**/*`],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: pkgName,
|
||||
message: `Please use relative imports within the package instead of "${pkgName}".`,
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
group: [`${pkgName}/*`],
|
||||
message: `Please use relative imports within the package instead of "${pkgName}/*".`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
// Skip packages that can't be parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cachedRules = configs;
|
||||
return configs;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// @ts-check
|
||||
import globals from 'globals';
|
||||
|
||||
/**
|
||||
* Relaxation of rules for test files and stories.
|
||||
*/
|
||||
export const testingConfig = [
|
||||
{
|
||||
files: [
|
||||
'**/*.{test,spec,stories}.{js,jsx,ts,tsx}',
|
||||
'.config/jest/**/*.{js,jsx,ts,tsx}',
|
||||
'**/test-utils.tsx',
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.jest,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Relax rules for test files, stories and jest configuration
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/consistent-type-assertions': 'off',
|
||||
|
||||
// Disable all JSDoc rules
|
||||
'jsdoc/require-jsdoc': 'off',
|
||||
'jsdoc/require-description': 'off',
|
||||
'jsdoc/require-param': 'off',
|
||||
'jsdoc/require-returns': 'off',
|
||||
'jsdoc/require-example': 'off',
|
||||
'jsdoc/check-alignment': 'off',
|
||||
'jsdoc/check-indentation': 'off',
|
||||
'jsdoc/check-syntax': 'off',
|
||||
'jsdoc/check-tag-names': 'off',
|
||||
'jsdoc/check-types': 'off',
|
||||
'jsdoc/valid-types': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,29 @@
|
||||
// @ts-check
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
/**
|
||||
* Rules for tooling, config files, and CommonJS files.
|
||||
*/
|
||||
export const toolingConfig = [
|
||||
// CommonJS files - disable all TypeScript rules
|
||||
{
|
||||
files: ['**/*.cjs'],
|
||||
...tseslint.configs.disableTypeChecked,
|
||||
},
|
||||
|
||||
// Config files and tooling
|
||||
{
|
||||
files: ['*.config.{js,ts,mjs,cjs}', '.storybook/**/*.{js,ts,cjs,mjs}'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/consistent-type-assertions': 'off',
|
||||
'jsdoc/require-jsdoc': 'off',
|
||||
'no-undef': 'off', // For CommonJS files
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,58 @@
|
||||
// @ts-check
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
/**
|
||||
* TypeScript configuration.
|
||||
*
|
||||
* @param {string} rootDir - The root directory for the TypeScript parser.
|
||||
* @returns {import('eslint').Linter.Config[]} ESLint configurations.
|
||||
*/
|
||||
export function getTypescriptConfig(rootDir) {
|
||||
return [
|
||||
// TypeScript - Strict Configuration (only for .ts/.tsx files)
|
||||
...[...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.stylisticTypeChecked].map(
|
||||
(config) => ({
|
||||
...config,
|
||||
files: ['**/*.{ts,tsx,mts,cts}'],
|
||||
})
|
||||
),
|
||||
{
|
||||
files: ['**/*.{ts,tsx,mts,cts}'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: rootDir,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// TypeScript strict rules
|
||||
'@typescript-eslint/no-explicit-any': 'error',
|
||||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'error',
|
||||
'@typescript-eslint/prefer-optional-chain': 'error',
|
||||
'@typescript-eslint/no-unnecessary-condition': 'error',
|
||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||
'@typescript-eslint/prefer-as-const': 'error',
|
||||
'@typescript-eslint/consistent-type-definitions': ['error', 'interface'],
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', disallowTypeAnnotations: false },
|
||||
],
|
||||
'@typescript-eslint/consistent-type-assertions': [
|
||||
'error',
|
||||
{
|
||||
assertionStyle: 'never',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
},
|
||||
},
|
||||
// Allow triple-slash references in `*.d.ts` files.
|
||||
{
|
||||
files: ['**/*.d.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/triple-slash-reference': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// @ts-check
|
||||
import path from 'node:path';
|
||||
import { defineBaseConfig } from './config/index.js';
|
||||
|
||||
// Resolve the monorepo root relative to this library's location
|
||||
const rootDir = path.resolve(import.meta.dirname, '../../../../');
|
||||
|
||||
/**
|
||||
* Default base configuration for the monorepo.
|
||||
*/
|
||||
export const baseConfig = defineBaseConfig(rootDir);
|
||||
|
||||
export { defineBaseConfig };
|
||||
@@ -0,0 +1,2 @@
|
||||
// Runtime entry point (empty)
|
||||
export {};
|
||||
@@ -0,0 +1,2 @@
|
||||
// Testing helpers (empty)
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/testing.js';
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@ermnvldmr/i18n",
|
||||
"version": "1.0.0",
|
||||
"description": "Internationalization utilities for ermnvldmr projects",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./dev": {
|
||||
"import": "./dist/dev.js",
|
||||
"types": "./dist/dev.d.ts",
|
||||
"default": "./src/dev.ts"
|
||||
},
|
||||
"./testing": {
|
||||
"import": "./dist/testing.js",
|
||||
"types": "./dist/testing.d.ts",
|
||||
"default": "./src/testing.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rsbuild build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist coverage"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ermnvldmr/eslint-config": "workspace:*",
|
||||
"@ermnvldmr/rsbuild-config": "workspace:*",
|
||||
"@rsbuild/core": "^1.2.11",
|
||||
"@types/react": "^19.1.2",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"react": "^19.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { definePackageConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
|
||||
export default definePackageConfig({
|
||||
source: {
|
||||
entry: {
|
||||
index: './src/index.ts',
|
||||
dev: './src/dev.ts',
|
||||
testing: './src/testing.ts',
|
||||
},
|
||||
alias: { '@': './src' },
|
||||
},
|
||||
output: {
|
||||
target: 'web',
|
||||
distPath: { root: 'dist', js: '' },
|
||||
filename: { js: '[name].js' },
|
||||
cleanDistPath: true,
|
||||
},
|
||||
tools: {
|
||||
htmlPlugin: false,
|
||||
rspack: {
|
||||
output: { library: { type: 'module' } },
|
||||
experiments: { outputModule: true },
|
||||
externals: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime'],
|
||||
externalsType: 'module',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { DEFAULT_LOCALE, isLocale, type Locale } from './lib/locale.js';
|
||||
|
||||
import type { RsbuildConfig, RsbuildPlugin } from '@rsbuild/core';
|
||||
|
||||
/**
|
||||
* Returns a partial Rsbuild config that enables locale-aware builds.
|
||||
*
|
||||
* The locale MDX replacement is provided as an Rsbuild plugin (rather than a
|
||||
* `tools.rspack` function) so that `mergeConfig` can safely concatenate it
|
||||
* with the service's own `plugins` array. A `tools.rspack` function would be
|
||||
* silently dropped by `mergeConfig` when the service config also defines
|
||||
* `tools.rspack` as a plain object.
|
||||
*
|
||||
* Designed to be merged with defineServiceConfig() in each service's rsbuild.config.ts:
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { localeRsbuildConfig } from '@ermnvldmr/i18n/dev';
|
||||
* import { defineServiceConfig, mergeConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
*
|
||||
* export default defineServiceConfig(mergeConfig(localeRsbuildConfig(), { ... }));
|
||||
* ```
|
||||
*/
|
||||
export function localeRsbuildConfig(): RsbuildConfig {
|
||||
const rawLocale = process.env.LOCALE ?? '';
|
||||
const locale: Locale = isLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
|
||||
|
||||
const localePlugins: RsbuildPlugin[] = [];
|
||||
|
||||
if (locale !== DEFAULT_LOCALE) {
|
||||
localePlugins.push({
|
||||
name: 'ermnvldmr:locale-mdx-replacement',
|
||||
setup(api) {
|
||||
// Resolve .en.mdx imports to the locale-specific file when LOCALE != 'en'.
|
||||
// The canonical import is always `.en.mdx`; other locales are resolved here.
|
||||
api.modifyRspackConfig((config, { rspack }) => {
|
||||
config.plugins = [
|
||||
...config.plugins,
|
||||
new rspack.NormalModuleReplacementPlugin(
|
||||
/\.en\.mdx$/,
|
||||
(resource: { request: string }) => {
|
||||
resource.request = resource.request.replace(/\.en\.mdx$/, `.${locale}.mdx`);
|
||||
}
|
||||
),
|
||||
];
|
||||
return config;
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
source: {
|
||||
define: {
|
||||
'process.env.LOCALE': JSON.stringify(locale),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// Dev server serves pages under the locale base so localePath() links
|
||||
// resolve correctly in dev without any NODE_ENV branching.
|
||||
base: `/${locale}`,
|
||||
},
|
||||
output: {
|
||||
distPath: {
|
||||
root: `dist/${locale}`,
|
||||
},
|
||||
assetPrefix: `/${locale}/`,
|
||||
},
|
||||
plugins: localePlugins,
|
||||
};
|
||||
}
|
||||
|
||||
export { LOCALES, DEFAULT_LOCALE, type Locale } from './lib/locale.js';
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './lib/locale.js';
|
||||
export * from './lib/pluralize.js';
|
||||
export * from './lib/keyset.js';
|
||||
export * from './lib/path.js';
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { createKeyset } from './keyset.js';
|
||||
|
||||
const { t, tRaw } = createKeyset({
|
||||
greeting: { en: 'Hello', ru: 'Привет' },
|
||||
'count {value}': { en: 'count {value}', ru: 'счёт {value}' },
|
||||
items: {
|
||||
en: { none: 'no items', one: 'one item', some: '{count} items', many: '{count} items' },
|
||||
ru: {
|
||||
none: 'нет элементов',
|
||||
one: 'один элемент',
|
||||
some: '{count} элемента',
|
||||
many: '{count} элементов',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('t', () => {
|
||||
it('returns plain translation', () => expect(t('greeting')).toBe('Hello'));
|
||||
it('interpolates {value}', () => expect(t('count {value}', { value: '42' })).toBe('count 42'));
|
||||
it('pluralizes none', () => expect(t('items', { count: 0 })).toBe('no items'));
|
||||
it('pluralizes one', () => expect(t('items', { count: 1 })).toBe('one item'));
|
||||
it('pluralizes many with interpolation', () => expect(t('items', { count: 5 })).toBe('5 items'));
|
||||
});
|
||||
|
||||
describe('tRaw', () => {
|
||||
it('returns string node for plain key', () => expect(tRaw('greeting')).toBe('Hello'));
|
||||
it('interpolates ReactNode', () => {
|
||||
const node = tRaw('count {value}', { value: React.createElement('strong', null, '42') });
|
||||
expect(Array.isArray(node)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { LOCALE } from './locale.js';
|
||||
import { pluralize, type PluralForms } from './pluralize.js';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** A translation entry with a plain string per locale. */
|
||||
interface PlainTranslation {
|
||||
en: string;
|
||||
ru: string;
|
||||
}
|
||||
|
||||
/** A translation entry with plural forms per locale. */
|
||||
interface PluralTranslation {
|
||||
en: PluralForms;
|
||||
ru: PluralForms;
|
||||
}
|
||||
|
||||
/** Union of all supported translation shapes. */
|
||||
type Translation = PlainTranslation | PluralTranslation;
|
||||
|
||||
/** A map of translation keys to their translation entries. */
|
||||
type Keyset = Record<string, Translation>;
|
||||
|
||||
/**
|
||||
* Returns `true` when the translation entry uses plural forms.
|
||||
*
|
||||
* @param t - The translation entry to inspect.
|
||||
* @returns Type predicate narrowing `t` to {@link PluralTranslation}.
|
||||
* @example
|
||||
* isPluralTranslation({ en: 'hello', ru: 'привет' }) // → false
|
||||
*/
|
||||
function isPluralTranslation(t: Translation): t is PluralTranslation {
|
||||
return typeof t.en === 'object';
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces `{key}` placeholders in `template` with values from `params`.
|
||||
*
|
||||
* @param template - String containing `{key}` placeholders.
|
||||
* @param params - Map of placeholder names to string/number values.
|
||||
* @returns The interpolated string.
|
||||
* @example
|
||||
* interpolate('Hello {name}!', { name: 'World' }) // → 'Hello World!'
|
||||
*/
|
||||
function interpolate(template: string, params: Record<string, string | number>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) =>
|
||||
key in params ? String(params[key]) : `{${key}}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits `template` on `{key}` placeholders and substitutes ReactNode values.
|
||||
*
|
||||
* @param template - String containing `{key}` placeholders.
|
||||
* @param params - Map of placeholder names to ReactNode values.
|
||||
* @returns A single string when all parts are strings, otherwise a ReactNode array.
|
||||
* @example
|
||||
* interpolateRaw('Click {link}', { link: <a href="/">here</a> })
|
||||
*/
|
||||
function interpolateRaw(template: string, params: Record<string, ReactNode>): ReactNode {
|
||||
const parts = template.split(/(\{[^}]+\})/g);
|
||||
const result = parts.map((part) => {
|
||||
const match = /^\{(\w+)\}$/.exec(part);
|
||||
if (match) {
|
||||
const key = match[1];
|
||||
return key in params ? params[key] : part;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
if (result.every((p): p is string => typeof p === 'string')) {
|
||||
return result.join('');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a typed `t` and `tRaw` pair bound to `keyset`.
|
||||
*
|
||||
* @param keyset - An object mapping translation keys to their locale entries.
|
||||
* @returns `{ t, tRaw }` translation helpers.
|
||||
* @example
|
||||
* const { t } = createKeyset({ greeting: { en: 'Hello', ru: 'Привет' } });
|
||||
* t('greeting') // → 'Hello' (in EN build)
|
||||
*/
|
||||
export function createKeyset<const K extends Keyset>(keyset: K) {
|
||||
/** String union of all keys defined in `keyset`. */
|
||||
type Keys = keyof K & string;
|
||||
|
||||
/**
|
||||
* Returns the translated string for `key`, with optional interpolation and pluralisation.
|
||||
*
|
||||
* @param key - A key defined in the keyset.
|
||||
* @param params - Optional interpolation/pluralisation params.
|
||||
* @returns The translated string for the current build locale.
|
||||
* @example
|
||||
* t('greeting') // → 'Hello'
|
||||
* t('items {count}', { count: 3 }) // → '3 items'
|
||||
*/
|
||||
function t(key: Keys, params?: Record<string, string | number>): string {
|
||||
const entry = keyset[key];
|
||||
|
||||
if (isPluralTranslation(entry)) {
|
||||
const forms = entry[LOCALE];
|
||||
const count = typeof params?.count === 'number' ? params.count : 0;
|
||||
const template = pluralize(count, forms);
|
||||
return params ? interpolate(template, params) : template;
|
||||
}
|
||||
|
||||
const template = entry[LOCALE];
|
||||
return params ? interpolate(template, params) : template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the translated ReactNode for `key`, allowing ReactNode interpolation values.
|
||||
*
|
||||
* @param key - A key defined in the keyset.
|
||||
* @param params - Optional interpolation params; values may be any ReactNode.
|
||||
* @returns The translated string or ReactNode array for the current build locale.
|
||||
* @example
|
||||
* tRaw('click {link}', { link: <a href="/">here</a> })
|
||||
*/
|
||||
function tRaw(key: Keys, params?: Record<string, ReactNode>): ReactNode {
|
||||
const entry = keyset[key];
|
||||
|
||||
if (isPluralTranslation(entry)) {
|
||||
const forms = entry[LOCALE];
|
||||
const countParam = params?.count;
|
||||
const count = typeof countParam === 'number' ? countParam : 0;
|
||||
const template = pluralize(count, forms);
|
||||
return params ? interpolateRaw(template, params) : template;
|
||||
}
|
||||
|
||||
const template = entry[LOCALE];
|
||||
return params ? interpolateRaw(template, params) : template;
|
||||
}
|
||||
|
||||
return { t, tRaw };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** The supported locale identifiers. */
|
||||
export type Locale = 'en' | 'ru';
|
||||
|
||||
export const LOCALES: readonly Locale[] = ['en', 'ru'] as const;
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = 'en';
|
||||
|
||||
// Widened once so inclusion checks need no type assertions.
|
||||
const LOCALES_STRINGS: readonly string[] = LOCALES;
|
||||
|
||||
/**
|
||||
* Returns `true` when `value` is a known {@link Locale} identifier.
|
||||
*
|
||||
* @param value - The string to test.
|
||||
* @returns Type predicate narrowing `value` to `Locale`.
|
||||
* @example
|
||||
* isLocale('en') // → true
|
||||
* isLocale('fr') // → false
|
||||
*/
|
||||
export function isLocale(value: string): value is Locale {
|
||||
return LOCALES_STRINGS.includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active locale for the current build.
|
||||
* Injected at compile time via source.define → process.env.LOCALE.
|
||||
* Falls back to DEFAULT_LOCALE in non-locale-aware builds (tests, Storybook).
|
||||
*/
|
||||
const rawLocale = process.env.LOCALE ?? '';
|
||||
export const LOCALE: Locale = isLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
|
||||
|
||||
/**
|
||||
* The other (non-active) locale for the current build.
|
||||
* Constant-folded at build time.
|
||||
*/
|
||||
export const OTHER_LOCALE: Locale = LOCALES.find((l) => l !== LOCALE) ?? DEFAULT_LOCALE;
|
||||
|
||||
/**
|
||||
* Human-readable label for switching to the other locale,
|
||||
* expressed in the CURRENT locale's language.
|
||||
* - EN build → "In Russian"
|
||||
* - RU build → "На Английском"
|
||||
*/
|
||||
const SWITCH_LABELS: Record<Locale, Partial<Record<Locale, string>>> = {
|
||||
en: { ru: 'In Russian' },
|
||||
ru: { en: 'На Английском' },
|
||||
};
|
||||
|
||||
export const OTHER_LOCALE_LABEL: string =
|
||||
SWITCH_LABELS[LOCALE][OTHER_LOCALE] ?? OTHER_LOCALE.toUpperCase();
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { localePath, localeHref } from './path.js';
|
||||
|
||||
describe('localePath', () => {
|
||||
it('prepends LOCALE to absolute path', () =>
|
||||
expect(localePath('/articles')).toBe('/en/articles'));
|
||||
it('handles root path', () => expect(localePath('/')).toBe('/en/'));
|
||||
it('handles nested path', () =>
|
||||
expect(localePath('/articles/2026/test')).toBe('/en/articles/2026/test'));
|
||||
});
|
||||
|
||||
describe('localeHref', () => {
|
||||
it('replaces locale prefix in URL', () =>
|
||||
expect(localeHref('/en/articles', 'ru')).toBe('/ru/articles'));
|
||||
it('replaces locale at root', () => expect(localeHref('/en/', 'ru')).toBe('/ru/'));
|
||||
it('falls back to path if no prefix found', () =>
|
||||
expect(localeHref('/articles', 'ru')).toBe('/ru/articles'));
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { LOCALE, LOCALES, type Locale } from './locale.js';
|
||||
|
||||
/**
|
||||
* Resolves any href value to its locale-aware form.
|
||||
*
|
||||
* Only absolute internal paths (starting with `/`) are rewritten; everything else
|
||||
* (external URLs, mailto/tel, relative paths, hash anchors) passes through unchanged.
|
||||
* The function is idempotent: paths already carrying any known locale prefix are returned as-is.
|
||||
*
|
||||
* Works identically in dev and production: the dev server is configured with
|
||||
* `server.base = /${LOCALE}` via `localeRsbuildConfig`.
|
||||
*
|
||||
* @param href - The href value to resolve.
|
||||
* @returns The locale-prefixed href, or the original value when no rewrite is needed.
|
||||
* @example
|
||||
* // LOCALE = 'en'
|
||||
* localePath('/articles') // → '/en/articles'
|
||||
* localePath('/') // → '/en/'
|
||||
* localePath('/en/articles') // → '/en/articles' (idempotent)
|
||||
* localePath('https://github.com') // → 'https://github.com' (external)
|
||||
* localePath('mailto:a@b.com') // → 'mailto:a@b.com'
|
||||
* localePath('#section') // → '#section'
|
||||
*/
|
||||
export function localePath(href: string): string;
|
||||
export function localePath(href: null): null;
|
||||
export function localePath(href: undefined): undefined;
|
||||
export function localePath(href: string | undefined): string | undefined;
|
||||
export function localePath(href: string | null): string | null;
|
||||
export function localePath(href: string | null | undefined): string | null | undefined;
|
||||
export function localePath(href: string | null | undefined): string | null | undefined {
|
||||
if (href == null) return href;
|
||||
if (!href.startsWith('/')) return href;
|
||||
if (LOCALES.some((l) => href === `/${l}` || href === `/${l}/` || href.startsWith(`/${l}/`))) {
|
||||
return href;
|
||||
}
|
||||
if (href === '/') return `/${LOCALE}/`;
|
||||
return `/${LOCALE}${href}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a URL for a specific locale given the current locale-prefixed URL.
|
||||
*
|
||||
* @param currentPath - The current page path, e.g. `/en/articles`.
|
||||
* @param locale - The target locale.
|
||||
* @returns The equivalent path for `locale`, e.g. `/ru/articles`.
|
||||
* @example
|
||||
* localeHref('/en/articles', 'ru') // → '/ru/articles'
|
||||
* localeHref('/en/', 'ru') // → '/ru/'
|
||||
*/
|
||||
export function localeHref(currentPath: string, locale: Locale): string {
|
||||
const localePrefix = new RegExp(`^\\/(${LOCALES.join('|')})(.*)`);
|
||||
const match = localePrefix.exec(currentPath);
|
||||
if (match) {
|
||||
return `/${locale}${match[2] || '/'}`;
|
||||
}
|
||||
return `/${locale}${currentPath}`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { pluralize } from './pluralize.js';
|
||||
|
||||
const forms = { none: 'none', one: 'one', some: 'some', many: 'many' };
|
||||
|
||||
describe('pluralize', () => {
|
||||
it('returns none for 0', () => expect(pluralize(0, forms)).toBe('none'));
|
||||
it('returns one for 1', () => expect(pluralize(1, forms)).toBe('one'));
|
||||
it('returns some for 2', () => expect(pluralize(2, forms)).toBe('some'));
|
||||
it('returns some for 4', () => expect(pluralize(4, forms)).toBe('some'));
|
||||
it('returns many for 5', () => expect(pluralize(5, forms)).toBe('many'));
|
||||
it('returns many for 11', () => expect(pluralize(11, forms)).toBe('many'));
|
||||
it('returns many for 12', () => expect(pluralize(12, forms)).toBe('many'));
|
||||
it('returns many for 100', () => expect(pluralize(100, forms)).toBe('many'));
|
||||
it('returns one for 21', () => expect(pluralize(21, forms)).toBe('one'));
|
||||
it('returns some for 22', () => expect(pluralize(22, forms)).toBe('some'));
|
||||
it('returns many for 111', () => expect(pluralize(111, forms)).toBe('many'));
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/** The four plural categories used across EN and RU grammar rules. */
|
||||
export interface PluralForms {
|
||||
none: string;
|
||||
one: string;
|
||||
some: string;
|
||||
many: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the correct plural form for the given count.
|
||||
* Covers both English (none/one/many) and Russian (none/one/some/many) rules.
|
||||
*
|
||||
* @param count - The numeric value to pluralise.
|
||||
* @param forms - The set of plural forms to select from.
|
||||
* @returns The appropriate plural form string.
|
||||
* @example
|
||||
* const forms = { none: 'no items', one: 'one item', some: '{n} items', many: '{n} items' };
|
||||
* pluralize(0, forms) // → 'no items'
|
||||
* pluralize(1, forms) // → 'one item'
|
||||
* pluralize(3, forms) // → '{n} items' (some)
|
||||
* pluralize(21, forms) // → 'one item' (Russian rule: 21 ends in 1)
|
||||
*/
|
||||
export function pluralize(count: number, forms: PluralForms): string {
|
||||
if (count === 0) return forms.none;
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return forms.one;
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return forms.some;
|
||||
return forms.many;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { LOCALE, DEFAULT_LOCALE, isLocale } from './lib/locale.js';
|
||||
export { localePath, localeHref } from './lib/path.js';
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "@ermnvldmr/jest-config",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared Jest configuration",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./base": "./dist/base.js",
|
||||
"./react": "./dist/react.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jest/globals": "^30.2.0",
|
||||
"jest": "^30.2.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"@types/jest": "^30.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"jest-environment-jsdom": "^30.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { Config } from 'jest';
|
||||
|
||||
/**
|
||||
* Base Jest configuration for TypeScript projects.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { baseJestConfig } from '@ermnvldmr/jest-config/base';
|
||||
*
|
||||
* export default {
|
||||
* ...baseJestConfig,
|
||||
* // package-specific overrides
|
||||
* } satisfies Config;
|
||||
* ```
|
||||
*/
|
||||
export const baseJestConfig: Config = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/**/*.test.{ts,tsx}',
|
||||
'!src/**/*.stories.{ts,tsx}',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.{ts,tsx}',
|
||||
'**/*.{test,spec}.{ts,tsx}',
|
||||
],
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Jest configuration exports for the monorepo.
|
||||
*/
|
||||
|
||||
export { baseJestConfig } from './base.js';
|
||||
export { reactJestConfig } from './react.js';
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Config } from 'jest';
|
||||
import { baseJestConfig } from './base.js';
|
||||
|
||||
/**
|
||||
* Jest configuration for React projects with jsdom environment.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { reactJestConfig } from '@ermnvldmr/jest-config/react';
|
||||
*
|
||||
* export default reactJestConfig;
|
||||
* ```
|
||||
*/
|
||||
export const reactJestConfig: Config = {
|
||||
...baseJestConfig,
|
||||
testEnvironment: 'jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
|
||||
moduleNameMapper: {
|
||||
...baseJestConfig.moduleNameMapper,
|
||||
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
||||
},
|
||||
transform: {
|
||||
...baseJestConfig.transform,
|
||||
'^.+\\.(js|jsx|ts|tsx)$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@ermnvldmr/rsbuild-config",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./dev": "./dist/dev.js",
|
||||
"./testing": "./dist/testing.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@rsbuild/plugin-react": "^1.1.1",
|
||||
"@rsbuild/plugin-type-check": "^1.1.1",
|
||||
"critters": "^0.0.25",
|
||||
"fast-glob": "^3.3.3",
|
||||
"jsdom": "^28.0.0",
|
||||
"react": "^19.1.0",
|
||||
"rsbuild-plugin-dts": "^0.19.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rsbuild/core": "^1.7.3",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"react-dom": "^19.1.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vite-tsconfig-paths": "^6.1.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/dev.js';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/index.js';
|
||||
@@ -0,0 +1,31 @@
|
||||
import { join } from 'node:path';
|
||||
import fg from 'fast-glob';
|
||||
|
||||
export function discoverEntries(root: string, pattern: string): Record<string, string> {
|
||||
const files = fg.sync(pattern, { cwd: root });
|
||||
const entries: Record<string, string> = {};
|
||||
|
||||
for (const file of files) {
|
||||
// We expect the user to provide a pattern like './src/app/*.tsx'
|
||||
// We'll strip the directory parts to get the name
|
||||
const parts = file.split('/');
|
||||
// Remove extension from the last part
|
||||
const filename = parts[parts.length - 1].replace(/\.tsx$/, '');
|
||||
|
||||
// For nested routes, we might want to keep the path but relative to the app root
|
||||
// But since you want 'index', 'articles/index', etc.
|
||||
// Let's just use the file path relative to the common root 'src/app' if it exists in the path
|
||||
const appMatch = file.match(/src\/app\/(.*)\.tsx$/);
|
||||
let name = appMatch ? appMatch[1] : filename;
|
||||
|
||||
// If it's src/app/articles/index.tsx -> name is 'articles/index'
|
||||
// We want it to be 'articles/index' to generate 'dist/articles/index.html'
|
||||
// Rsbuild automatically maps 'articles/index' entry to 'dist/articles/index.html'
|
||||
// The previous replace(/\/index$/, '') was stripping the 'index' making it 'articles'
|
||||
// which generates 'dist/articles.html' instead of 'dist/articles/index.html'
|
||||
|
||||
entries[name] = join(root, file);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './rsbuild.js';
|
||||
export * from './vitest.js';
|
||||
export * from './entries.js';
|
||||
@@ -0,0 +1,75 @@
|
||||
import { defineConfig, type RsbuildConfig } from '@rsbuild/core';
|
||||
import { pluginReact } from '@rsbuild/plugin-react';
|
||||
import { pluginTypeCheck } from '@rsbuild/plugin-type-check';
|
||||
import { pluginDts } from 'rsbuild-plugin-dts';
|
||||
|
||||
import { mergeConfig } from '../utils/merge.js';
|
||||
import { ssgPlugin } from './ssg.js';
|
||||
|
||||
/**
|
||||
* Defines a standard Rsbuild configuration for services (apps).
|
||||
*
|
||||
* @param config - Optional Rsbuild configuration overrides
|
||||
* @returns A complete Rsbuild configuration object
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { defineServiceConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
* export default defineServiceConfig({
|
||||
* source: { entry: { index: './src/main.ts' } }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function defineServiceConfig(config: RsbuildConfig = {}): RsbuildConfig {
|
||||
const defaultConfig: RsbuildConfig = {
|
||||
plugins: [pluginReact(), pluginTypeCheck(), ssgPlugin()],
|
||||
output: {
|
||||
distPath: {
|
||||
font: 'static/font',
|
||||
},
|
||||
filename: {
|
||||
font: '[name][ext]',
|
||||
},
|
||||
},
|
||||
html: {
|
||||
template: './src/static/index.html',
|
||||
meta: {
|
||||
description: 'Thoughts and creations of mine',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return defineConfig(mergeConfig(defaultConfig, config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a standard Rsbuild configuration for packages (libraries).
|
||||
*
|
||||
* @param config - Optional Rsbuild configuration overrides
|
||||
* @returns A complete Rsbuild configuration object
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { definePackageConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
* export default definePackageConfig({
|
||||
* output: { target: 'node' }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function definePackageConfig(config: RsbuildConfig = {}): RsbuildConfig {
|
||||
const defaultConfig: RsbuildConfig = {
|
||||
plugins: [pluginReact(), pluginTypeCheck(), pluginDts()],
|
||||
output: {
|
||||
distPath: {
|
||||
root: './dist',
|
||||
js: '.',
|
||||
},
|
||||
target: 'web',
|
||||
},
|
||||
tools: {
|
||||
htmlPlugin: false,
|
||||
},
|
||||
};
|
||||
|
||||
return defineConfig(mergeConfig(defaultConfig, config));
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
import React from 'react';
|
||||
import type { RsbuildPlugin } from '@rsbuild/core';
|
||||
|
||||
/**
|
||||
* Rsbuild plugin for build-time Static Site Generation.
|
||||
*
|
||||
* After the web build completes:
|
||||
* 1. Builds each entry as a Node.js CJS bundle (dist-ssr/)
|
||||
* 2. Renders each entry with react-dom/server renderToString
|
||||
* 3. Injects the rendered HTML into the web build's HTML files
|
||||
* 4. Runs critters to inline critical CSS and make the main stylesheet async
|
||||
* 5. Cleans up dist-ssr/
|
||||
*/
|
||||
export function ssgPlugin(): RsbuildPlugin {
|
||||
return {
|
||||
name: 'ermnvldmr:ssg-plugin',
|
||||
setup(api) {
|
||||
api.onAfterBuild(async ({ stats }) => {
|
||||
if (!stats) {
|
||||
console.warn('[ssg-plugin] No stats available, skipping SSG.');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = api.getRsbuildConfig();
|
||||
const rawEntries = config.source?.entry;
|
||||
|
||||
if (!rawEntries || typeof rawEntries !== 'object' || Array.isArray(rawEntries)) {
|
||||
console.warn('[ssg-plugin] source.entry is not an object map, skipping SSG.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalise entries: value may be a string path or an EntryDescription object
|
||||
const entries: Record<string, string> = {};
|
||||
for (const [name, value] of Object.entries(rawEntries)) {
|
||||
if (typeof value === 'string') {
|
||||
entries[name] = value;
|
||||
} else if (value && typeof value === 'object' && 'import' in value) {
|
||||
const imp = (value as { import: string | string[] }).import;
|
||||
entries[name] = Array.isArray(imp) ? imp[0] : imp;
|
||||
}
|
||||
}
|
||||
|
||||
const entryNames = Object.keys(entries);
|
||||
if (entryNames.length === 0) {
|
||||
console.warn('[ssg-plugin] No entries to process, skipping SSG.');
|
||||
return;
|
||||
}
|
||||
|
||||
// api.context.distPath is the absolute path to the web build output directory.
|
||||
// Fallback: derive from rootPath + output.distPath.root if the property is absent.
|
||||
const webDistPath: string =
|
||||
(api.context as { distPath?: string }).distPath ??
|
||||
path.join(
|
||||
api.context.rootPath,
|
||||
(config.output?.distPath as { root?: string } | undefined)?.root ?? 'dist'
|
||||
);
|
||||
const ssrDistPath = path.join(path.dirname(webDistPath), 'dist-ssr');
|
||||
|
||||
// ── Step 1: Build node-target CJS bundles ─────────────────────────────
|
||||
console.log('[ssg-plugin] Building SSR bundles...');
|
||||
const { createRsbuild } = await import('@rsbuild/core');
|
||||
|
||||
// Reuse web build plugins minus ssgPlugin (avoids recursion) and pluginTypeCheck
|
||||
// (type-checking is not needed for the SSR build). This automatically includes
|
||||
// pluginReact, pluginMdx, and any other service-level plugins.
|
||||
const SSG_PLUGIN_NAME = 'ermnvldmr:ssg-plugin';
|
||||
const TYPE_CHECK_PLUGIN_NAME = 'rsbuild:type-check';
|
||||
const ssrPlugins = (config.plugins ?? []).filter((p) => {
|
||||
if (!p || typeof p !== 'object') return true;
|
||||
const name = (p as { name?: string }).name;
|
||||
return name !== SSG_PLUGIN_NAME && name !== TYPE_CHECK_PLUGIN_NAME;
|
||||
});
|
||||
|
||||
const ssrBuild = await createRsbuild({
|
||||
rsbuildConfig: {
|
||||
plugins: ssrPlugins,
|
||||
source: { entry: entries },
|
||||
tools: config.tools,
|
||||
output: {
|
||||
target: 'node',
|
||||
distPath: { root: ssrDistPath, js: '.' },
|
||||
filename: { js: '[name].cjs' },
|
||||
// Externalize React so the SSR bundle shares the same React instance
|
||||
// as react-dom/server, preventing "null dispatcher" hook errors.
|
||||
externals: {
|
||||
react: 'commonjs react',
|
||||
'react-dom': 'commonjs react-dom',
|
||||
'react/jsx-runtime': 'commonjs react/jsx-runtime',
|
||||
'react/jsx-dev-runtime': 'commonjs react/jsx-dev-runtime',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await ssrBuild.build();
|
||||
console.log('[ssg-plugin] SSR bundles ready.');
|
||||
|
||||
// ── Step 2: Render, inject, inline critical CSS ───────────────────────
|
||||
const { renderToString } = await import('react-dom/server');
|
||||
|
||||
// createRequire from ESM context — needed to load CJS modules (critters, SSR bundles)
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
||||
const Critters = _require('critters') as new (opts?: {
|
||||
path?: string;
|
||||
publicPath?: string;
|
||||
preload?: string;
|
||||
inlineFonts?: boolean;
|
||||
pruneSource?: boolean;
|
||||
reduceInlineStyles?: boolean;
|
||||
}) => { process(html: string): Promise<string> };
|
||||
|
||||
// critters `path` is the filesystem root that corresponds to publicPath.
|
||||
// It resolves <link href="..."> to webDistPath. Use configured assetPrefix for locale builds.
|
||||
const configuredAssetPrefix =
|
||||
(config.output as { assetPrefix?: string } | undefined)?.assetPrefix ?? '/';
|
||||
|
||||
const critters = new Critters({
|
||||
path: webDistPath,
|
||||
publicPath: configuredAssetPrefix,
|
||||
preload: 'swap', // <link rel="preload" as="style" onload="this.rel='stylesheet'">
|
||||
inlineFonts: false, // fonts are handled via separate <link rel="preload"> tags
|
||||
pruneSource: false, // keep full CSS file for post-hydration correctness
|
||||
reduceInlineStyles: false, // preserve theme init styles (prevents .dark rule from being stripped)
|
||||
});
|
||||
|
||||
for (const entryName of entryNames) {
|
||||
const ssrBundlePath = path.join(ssrDistPath, `${entryName}.cjs`);
|
||||
const webHtmlPath = path.join(webDistPath, `${entryName}.html`);
|
||||
|
||||
// Clear module cache so each entry loads fresh
|
||||
const resolved = _require.resolve(ssrBundlePath);
|
||||
delete _require.cache[resolved];
|
||||
|
||||
const mod = _require(ssrBundlePath) as { default?: React.ComponentType };
|
||||
const Component = mod.default;
|
||||
|
||||
if (!Component) {
|
||||
console.warn(`[ssg-plugin] Entry "${entryName}" has no default export — skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Render to HTML string
|
||||
const renderedHtml = renderToString(
|
||||
React.createElement(React.StrictMode, null, React.createElement(Component))
|
||||
);
|
||||
|
||||
// Read web HTML, inject rendered content
|
||||
const source = await fs.readFile(webHtmlPath, 'utf8');
|
||||
const injected = source.replace(
|
||||
/<div id="root"[^>]*>\s*<\/div>/,
|
||||
`<div id="root">${renderedHtml}</div>`
|
||||
);
|
||||
|
||||
if (injected === source) {
|
||||
console.warn(
|
||||
`[ssg-plugin] Could not find <div id="root"> in ${webHtmlPath} — injection skipped.`
|
||||
);
|
||||
}
|
||||
|
||||
// Run critters: inline critical CSS, make main stylesheet async
|
||||
const processed = await critters.process(injected);
|
||||
|
||||
// Set <html lang> to the current build locale
|
||||
const locale = process.env.LOCALE ?? 'en';
|
||||
const localized = processed.replace(
|
||||
/<html([^>]*)\slang="[^"]*"/,
|
||||
`<html$1 lang="${locale}"`
|
||||
);
|
||||
|
||||
await fs.writeFile(webHtmlPath, localized, 'utf8');
|
||||
console.log(`[ssg-plugin] ✓ ${entryName}`);
|
||||
}
|
||||
|
||||
// ── Step 3: Cleanup ───────────────────────────────────────────────────
|
||||
await fs.rm(ssrDistPath, { recursive: true, force: true });
|
||||
console.log('[ssg-plugin] SSG complete. dist-ssr cleaned up.');
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
/**
|
||||
* Base Vitest configuration for the monorepo.
|
||||
*/
|
||||
export const baseVitestConfig = defineConfig({
|
||||
plugins: [tsconfigPaths()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
passWithNoTests: true,
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
exclude: ['node_modules/**', 'dist/**', '**/*.d.ts', '**/*.config.*', '**/*.stories.tsx'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* React-specific Vitest configuration.
|
||||
*/
|
||||
export const reactVitestConfig = defineConfig({
|
||||
test: {
|
||||
...baseVitestConfig.test,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./.config/vitest/setup.ts'],
|
||||
alias: {
|
||||
// Handle asset mocks if needed
|
||||
'\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
||||
},
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './config/index.js';
|
||||
export { mergeConfig } from './utils/merge.js';
|
||||
@@ -0,0 +1,2 @@
|
||||
// Runtime entry point (empty)
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './config/vitest.js';
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Minimal deep merge utility for Rsbuild configurations.
|
||||
* Concatenates arrays and recursively merges objects.
|
||||
*
|
||||
* @param source - The source object
|
||||
* @param target - The target object to merge into source
|
||||
* @returns The merged object
|
||||
*/
|
||||
export function mergeConfig<T>(source: any, target: any): T {
|
||||
if (target === undefined) return source;
|
||||
if (source === undefined) return target;
|
||||
|
||||
if (Array.isArray(source) && Array.isArray(target)) {
|
||||
return [...source, ...target] as any;
|
||||
}
|
||||
|
||||
if (
|
||||
source !== null &&
|
||||
typeof source === 'object' &&
|
||||
!Array.isArray(source) &&
|
||||
target !== null &&
|
||||
typeof target === 'object' &&
|
||||
!Array.isArray(target)
|
||||
) {
|
||||
const result = { ...source };
|
||||
for (const key in target) {
|
||||
if (Object.prototype.hasOwnProperty.call(target, key)) {
|
||||
result[key] = mergeConfig(source[key], target[key]);
|
||||
}
|
||||
}
|
||||
return result as any;
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/testing.js';
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"composite": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { baseJestConfig } from '@ermnvldmr/jest-config/base';
|
||||
|
||||
export default baseJestConfig;
|
||||
+31
-11
@@ -1,24 +1,35 @@
|
||||
{
|
||||
"name": "@ermnvldmr/stl",
|
||||
"version": "1.0.0",
|
||||
"description": "Common reusable dependencies",
|
||||
"description": "Common utilities for ermnvldmr projects",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./testing": {
|
||||
"import": "./dist/testing.js",
|
||||
"types": "./dist/testing.d.ts",
|
||||
"default": "./src/testing.ts"
|
||||
},
|
||||
"./dev": {
|
||||
"import": "./dist/dev.js",
|
||||
"types": "./dist/dev.d.ts",
|
||||
"default": "./src/dev.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc && vite build",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"build": "rsbuild build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist coverage"
|
||||
@@ -29,14 +40,23 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ermnvldmr/eslint-config": "workspace:*",
|
||||
"@ermnvldmr/jest-config": "workspace:*",
|
||||
"@ermnvldmr/vite-config": "workspace:*",
|
||||
"jest": "^30.2.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"@ermnvldmr/rsbuild-config": "workspace:*",
|
||||
"@rsbuild/core": "^1.2.11",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"jsdom": "^28.0.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.2.2"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"tailwindcss": "^4.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { definePackageConfig } from '@ermnvldmr/rsbuild-config/dev';
|
||||
|
||||
export default definePackageConfig({
|
||||
source: {
|
||||
entry: {
|
||||
index: './src/index.ts',
|
||||
testing: './src/testing.ts',
|
||||
dev: './src/dev.ts',
|
||||
},
|
||||
alias: {
|
||||
'@': './src',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
target: 'web',
|
||||
distPath: {
|
||||
root: 'dist',
|
||||
js: '',
|
||||
},
|
||||
filename: {
|
||||
js: '[name].js',
|
||||
},
|
||||
cleanDistPath: true,
|
||||
},
|
||||
tools: {
|
||||
htmlPlugin: false,
|
||||
rspack: {
|
||||
output: {
|
||||
library: {
|
||||
type: 'module',
|
||||
},
|
||||
},
|
||||
experiments: {
|
||||
outputModule: true,
|
||||
},
|
||||
externals: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'react/jsx-runtime',
|
||||
'react/jsx-dev-runtime',
|
||||
'tailwindcss',
|
||||
'clsx',
|
||||
'tailwind-merge',
|
||||
],
|
||||
externalsType: 'module',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/dev.js';
|
||||
@@ -1,2 +1 @@
|
||||
export * from './types';
|
||||
export * from './functions';
|
||||
export * from './lib/index.js';
|
||||
@@ -0,0 +1,2 @@
|
||||
// No library-specific development utilities currently
|
||||
export {};
|
||||
+10
-12
@@ -19,10 +19,10 @@ describe('shared/helpers/cn/cn', () => {
|
||||
// Test with actual conditional values (simulating real usage)
|
||||
const activeCondition = Math.random() > -1; // Always true but not constant
|
||||
const inactiveCondition = Math.random() < -1; // Always false but not constant
|
||||
|
||||
|
||||
expect(cn('p-4', activeCondition && 'bg-red-500')).toBe('p-4 bg-red-500');
|
||||
expect(cn('p-4', inactiveCondition && 'bg-red-500')).toBe('p-4');
|
||||
|
||||
|
||||
// Test null and undefined conditions
|
||||
const nullCondition: string | null = null;
|
||||
const undefinedCondition: string | undefined = undefined;
|
||||
@@ -36,7 +36,9 @@ describe('shared/helpers/cn/cn', () => {
|
||||
});
|
||||
|
||||
it('handles array inputs', () => {
|
||||
expect(cn(['flex', 'items-center'], 'justify-between')).toBe('flex items-center justify-between');
|
||||
expect(cn(['flex', 'items-center'], 'justify-between')).toBe(
|
||||
'flex items-center justify-between'
|
||||
);
|
||||
expect(cn(['p-4', null, 'bg-red-500'])).toBe('p-4 bg-red-500');
|
||||
});
|
||||
|
||||
@@ -50,11 +52,11 @@ describe('shared/helpers/cn/cn', () => {
|
||||
const getIsActive = (): boolean => true;
|
||||
const getPrimaryVariant = (): 'primary' | 'secondary' => 'primary';
|
||||
const getSecondaryVariant = (): 'primary' | 'secondary' => 'secondary';
|
||||
|
||||
|
||||
const isActive = getIsActive();
|
||||
const primaryVariant = getPrimaryVariant();
|
||||
const secondaryVariant = getSecondaryVariant();
|
||||
|
||||
|
||||
// Test primary variant
|
||||
expect(
|
||||
cn(
|
||||
@@ -84,12 +86,8 @@ describe('shared/helpers/cn/cn', () => {
|
||||
});
|
||||
|
||||
it('properly handles spacing and responsive classes', () => {
|
||||
expect(
|
||||
cn('p-2 p-4', 'sm:p-6', 'md:p-8')
|
||||
).toBe('p-4 sm:p-6 md:p-8');
|
||||
|
||||
expect(
|
||||
cn('m-2', 'mx-4', 'ml-6')
|
||||
).toBe('m-2 mx-4 ml-6');
|
||||
expect(cn('p-2 p-4', 'sm:p-6', 'md:p-8')).toBe('p-4 sm:p-6 md:p-8');
|
||||
|
||||
expect(cn('m-2', 'mx-4', 'ml-6')).toBe('m-2 mx-4 ml-6');
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,32 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { extendTailwindMerge } from 'tailwind-merge';
|
||||
|
||||
const RB_TYPES = ['display', 'headline', 'title', 'body', 'label'] as const;
|
||||
const RB_SIZES = ['l', 'm', 's'] as const;
|
||||
|
||||
const rbFontSizeClasses = RB_TYPES.flatMap((type) => RB_SIZES.map((size) => `${type}-${size}`));
|
||||
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: {
|
||||
classGroups: {
|
||||
'font-size': [{ 'text-rb': rbFontSizeClasses }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Combines and merges CSS class names using clsx and tailwind-merge.
|
||||
*
|
||||
*
|
||||
* This utility function combines the power of clsx for conditional class handling
|
||||
* with tailwind-merge for proper Tailwind CSS class deduplication and conflict resolution.
|
||||
*
|
||||
*
|
||||
* Tailwind-merge is configured to recognise `text-rb-{type}-{size}` classes
|
||||
* (e.g. `text-rb-body-m`) as font-size utilities so they do not conflict with
|
||||
* text-color classes such as `text-[var(--rb-text)]`.
|
||||
*
|
||||
* @param inputs - Class values to combine (strings, objects, arrays, etc.)
|
||||
* @returns Merged class string with conflicts resolved
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* cn('px-4 py-2', 'bg-blue-500') // 'px-4 py-2 bg-blue-500'
|
||||
File renamed without changes.
@@ -0,0 +1,5 @@
|
||||
export * from './types/index.js';
|
||||
export * from './functions/index.js';
|
||||
export * from './object/index.js';
|
||||
export * from './window/localStorage.js';
|
||||
export * from './react/index.js';
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Object utilities for common operations on objects.
|
||||
*/
|
||||
|
||||
export { merge } from './merge/merge';
|
||||
export type { MergeOptions } from './merge/merge';
|
||||
@@ -0,0 +1,108 @@
|
||||
import { merge } from './merge';
|
||||
|
||||
/**
|
||||
* Test suite for the merge utility function.
|
||||
*/
|
||||
describe('shared/helpers/object/merge', () => {
|
||||
it('deeply merges two objects, preserving source values by default', () => {
|
||||
const source = { a: 1, b: { c: 2 } };
|
||||
const target = { a: 3, b: { c: 4, d: 5 }, e: 6 };
|
||||
const result = merge(source, target);
|
||||
|
||||
expect(result).toEqual({
|
||||
a: 1, // force is false, so 1 is preserved
|
||||
b: {
|
||||
c: 2, // force is false, so 2 is preserved
|
||||
d: 5, // added because it was not in source
|
||||
},
|
||||
e: 6, // added because it was not in source
|
||||
});
|
||||
});
|
||||
|
||||
it('deeply merges two objects and overrides values when force is true', () => {
|
||||
const source = { a: 1, b: { c: 2 } };
|
||||
const target = { a: 3, b: { c: 4, d: 5 }, e: 6 };
|
||||
const result = merge(source, target, { force: true });
|
||||
|
||||
expect(result).toEqual({
|
||||
a: 3,
|
||||
b: {
|
||||
c: 4,
|
||||
d: 5,
|
||||
},
|
||||
e: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('concatenates arrays when both are present at the same key', () => {
|
||||
const source = { a: [1, 2] };
|
||||
const target = { a: [3, 4] };
|
||||
const result = merge(source, target);
|
||||
|
||||
expect(result).toEqual({ a: [1, 2, 3, 4] });
|
||||
});
|
||||
|
||||
it('handles primitive values at the root', () => {
|
||||
expect(merge(1, 2)).toBe(1);
|
||||
expect(merge(1, 2, { force: true })).toBe(2);
|
||||
});
|
||||
|
||||
it('handles mixed types between source and target', () => {
|
||||
const source = { a: { b: 1 } };
|
||||
const target = { a: [1, 2] };
|
||||
|
||||
// Source wins because force is false
|
||||
expect(merge(source, target)).toEqual({ a: { b: 1 } });
|
||||
|
||||
// Target wins because force is true
|
||||
expect(merge(source, target, { force: true })).toEqual({ a: [1, 2] });
|
||||
});
|
||||
|
||||
it('handles deep recursion with arrays and objects', () => {
|
||||
const source = {
|
||||
users: [
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
],
|
||||
config: {
|
||||
retries: 3,
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const target = {
|
||||
users: [{ id: 3, name: 'Doe' }],
|
||||
config: {
|
||||
retries: 5,
|
||||
timeout: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
const result = merge(source, target);
|
||||
|
||||
expect(result).toEqual({
|
||||
users: [
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
{ id: 3, name: 'Doe' },
|
||||
],
|
||||
config: {
|
||||
retries: 3,
|
||||
enabled: true,
|
||||
timeout: 1000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the source when target has undefined values', () => {
|
||||
const source = { a: 1 };
|
||||
const target = { a: undefined };
|
||||
expect(merge(source, target)).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
it('replaces undefined source with target', () => {
|
||||
const source = { a: undefined };
|
||||
const target = { a: 1 };
|
||||
expect(merge(source, target)).toEqual({ a: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unnecessary-type-assertion */
|
||||
/**
|
||||
* Options for the merge utility.
|
||||
*/
|
||||
export interface MergeOptions {
|
||||
/**
|
||||
* Whether to override existing primitive values in the source with values from the target.
|
||||
* If false, existing primitive values are preserved.
|
||||
* @default false
|
||||
*/
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deeply merges two values (objects, arrays, or primitives).
|
||||
*
|
||||
* Behavior:
|
||||
* - If both values are arrays, they are concatenated.
|
||||
* - If both values are objects, they are merged recursively.
|
||||
* - If encounter a primitive value, it is overridden only if `force` is set to true.
|
||||
* - If a key exists in target but not in source, it is added to the result.
|
||||
*
|
||||
* @param source - The source value to merge from
|
||||
* @param target - The target value to merge with
|
||||
* @param options - Merge configuration options
|
||||
* @returns The merged result
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* merge({ a: 1 }, { a: 2 }) // { a: 1 }
|
||||
* merge({ a: 1 }, { a: 2 }, { force: true }) // { a: 2 }
|
||||
* merge({ a: [1] }, { a: [2] }) // { a: [1, 2] }
|
||||
* merge({ a: { b: 1 } }, { a: { c: 2 } }) // { a: { b: 1, c: 2 } }
|
||||
* ```
|
||||
*/
|
||||
export function merge<T = any>(source: any, target: any, options: MergeOptions = {}): T {
|
||||
const { force = false } = options;
|
||||
|
||||
// If target is undefined, source wins
|
||||
if (target === undefined) {
|
||||
return source;
|
||||
}
|
||||
// If source is undefined, target wins
|
||||
if (source === undefined) {
|
||||
return target;
|
||||
}
|
||||
|
||||
const sourceIsArray = Array.isArray(source);
|
||||
const targetIsArray = Array.isArray(target);
|
||||
|
||||
// If both are arrays, concatenate
|
||||
if (sourceIsArray && targetIsArray) {
|
||||
return [...source, ...target] as any;
|
||||
}
|
||||
|
||||
const sourceIsObject = source !== null && typeof source === 'object' && !sourceIsArray;
|
||||
const targetIsObject = target !== null && typeof target === 'object' && !targetIsArray;
|
||||
|
||||
// If both are objects, deep merge
|
||||
if (sourceIsObject && targetIsObject) {
|
||||
const result = { ...source };
|
||||
for (const key in target) {
|
||||
if (Object.prototype.hasOwnProperty.call(target, key)) {
|
||||
if (key in source) {
|
||||
result[key] = merge(source[key], target[key], options);
|
||||
} else {
|
||||
result[key] = target[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return result as any;
|
||||
}
|
||||
|
||||
// If we encounter primitive values or mixed types, override only if force is true
|
||||
return force ? target : source;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type React from 'react';
|
||||
|
||||
/**
|
||||
* Safely casts a React ref to a mutable ref object.
|
||||
* This is a controlled escape hatch for situations where TypeScript's
|
||||
* built-in Ref types are too restrictive (e.g., when syncing refs manually).
|
||||
*
|
||||
* @param ref - The ref to cast
|
||||
* @returns The casted mutable ref
|
||||
* @example
|
||||
* ```tsx
|
||||
* const mutableRef = castMutableRef<HTMLDivElement>(ref);
|
||||
* mutableRef.current = someElement;
|
||||
* ```
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function castMutableRef<T>(ref: any): React.MutableRefObject<T | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return ref as React.MutableRefObject<T | null>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { castRef } from './castRef';
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
describe('react/castRef', () => {
|
||||
it('should cast a ref to a different type', () => {
|
||||
const ref = { current: null } as unknown as React.ForwardedRef<HTMLDivElement>;
|
||||
const casted = castRef<HTMLSpanElement>(ref);
|
||||
expect(casted).toBe(ref);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type React from 'react';
|
||||
|
||||
/**
|
||||
* Safely casts a React ref to a specific type.
|
||||
* This is a controlled escape hatch for situations where TypeScript's
|
||||
* built-in Ref types are too restrictive (e.g., in generic forwardRef components).
|
||||
*
|
||||
* @param ref - The ref to cast
|
||||
* @returns The casted ref
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Component ref={castRef<HTMLDivElement>(ref)} />
|
||||
* ```
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function castRef<T>(ref: any): React.RefObject<T> {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return ref as unknown as React.RefObject<T>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import { genericMemo } from './genericMemo';
|
||||
|
||||
describe('react/genericMemo', () => {
|
||||
it('should memoize a component', () => {
|
||||
const Component = ({ name }: { name: string }) => <div>{name}</div>;
|
||||
const Memoized = genericMemo(Component);
|
||||
|
||||
expect(Memoized).toBeDefined();
|
||||
// In React, memo returns a special object with type: component
|
||||
expect((Memoized as any).type).toBe(Component);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* A type-safe version of React.memo that preserves generic types.
|
||||
*
|
||||
* @param component - The component to memoize
|
||||
* @returns The memoized component
|
||||
* @example
|
||||
* ```tsx
|
||||
* export const MyComponent = genericMemo(MyGenericComponent);
|
||||
* ```
|
||||
*/
|
||||
export function genericMemo<T>(component: T): T {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-explicit-any
|
||||
return React.memo(component as any) as any as T;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { castRef } from './castRef/castRef';
|
||||
export { castMutableRef } from './castRef/castMutableRef';
|
||||
export { genericMemo } from './genericMemo/genericMemo';
|
||||
export { useResizeObserver } from './useResizeObserver/useResizeObserver';
|
||||
export { useIntersectionObserver } from './useIntersectionObserver/useIntersectionObserver';
|
||||
export { useScroll } from './useScroll/useScroll';
|
||||
export { useLocalStorage } from './useLocalStorage/useLocalStorage';
|
||||
|
||||
export type { ReactElementProps } from './types/ReactElementProps';
|
||||
@@ -0,0 +1,8 @@
|
||||
import type React from 'react';
|
||||
|
||||
/**
|
||||
* Common props for any React element.
|
||||
* Useful for forwarding props to dynamic components.
|
||||
*/
|
||||
export type ReactElementProps = React.HTMLAttributes<HTMLElement> &
|
||||
React.AllHTMLAttributes<HTMLElement>;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { useIntersectionObserver } from './useIntersectionObserver';
|
||||
|
||||
describe('useIntersectionObserver', () => {
|
||||
let observerCallback: IntersectionObserverCallback;
|
||||
let observeMock = vi.fn();
|
||||
let unobserveMock = vi.fn();
|
||||
let disconnectMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
observeMock = vi.fn();
|
||||
unobserveMock = vi.fn();
|
||||
disconnectMock = vi.fn();
|
||||
|
||||
class MockIntersectionObserver {
|
||||
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||
observerCallback = callback;
|
||||
this.root = options?.root ?? null;
|
||||
this.rootMargin = options?.rootMargin ?? '';
|
||||
this.thresholds = options?.threshold
|
||||
? Array.isArray(options.threshold)
|
||||
? options.threshold
|
||||
: [options.threshold]
|
||||
: [0];
|
||||
}
|
||||
root: Element | Document | null;
|
||||
rootMargin: string;
|
||||
thresholds: readonly number[];
|
||||
takeRecords = vi.fn();
|
||||
observe = observeMock;
|
||||
unobserve = unobserveMock;
|
||||
disconnect = disconnectMock;
|
||||
}
|
||||
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return initial state as not intersecting', () => {
|
||||
const ref = { current: document.createElement('div') };
|
||||
const { result } = renderHook(() => useIntersectionObserver(ref));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should call observe on mount', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
renderHook(() => useIntersectionObserver(ref));
|
||||
|
||||
expect(observeMock).toHaveBeenCalledWith(element);
|
||||
});
|
||||
|
||||
it('should update isIntersecting when observer triggers', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
const { result } = renderHook(() => useIntersectionObserver(ref));
|
||||
|
||||
const entries = [
|
||||
{
|
||||
isIntersecting: true,
|
||||
target: element,
|
||||
},
|
||||
] as unknown as IntersectionObserverEntry[];
|
||||
|
||||
act(() => {
|
||||
observerCallback(entries, {} as IntersectionObserver);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should disconnect after first intersection if once is true', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
renderHook(() => useIntersectionObserver(ref, { once: true }));
|
||||
|
||||
const entries = [
|
||||
{
|
||||
isIntersecting: true,
|
||||
target: element,
|
||||
},
|
||||
] as unknown as IntersectionObserverEntry[];
|
||||
|
||||
act(() => {
|
||||
observerCallback(entries, {} as IntersectionObserver);
|
||||
});
|
||||
|
||||
expect(disconnectMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not disconnect after first intersection if once is false', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
renderHook(() => useIntersectionObserver(ref, { once: false }));
|
||||
|
||||
const entries = [
|
||||
{
|
||||
isIntersecting: true,
|
||||
target: element,
|
||||
},
|
||||
] as unknown as IntersectionObserverEntry[];
|
||||
|
||||
act(() => {
|
||||
observerCallback(entries, {} as IntersectionObserver);
|
||||
});
|
||||
|
||||
expect(disconnectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call disconnect on unmount', () => {
|
||||
const element = document.createElement('div');
|
||||
const ref = { current: element };
|
||||
const { unmount } = renderHook(() => useIntersectionObserver(ref));
|
||||
|
||||
unmount();
|
||||
|
||||
expect(disconnectMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not observe if ref is null', () => {
|
||||
const ref = { current: null };
|
||||
renderHook(() => useIntersectionObserver(ref));
|
||||
|
||||
expect(observeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return true immediately if IntersectionObserver is not available', () => {
|
||||
vi.stubGlobal('IntersectionObserver', undefined);
|
||||
const ref = { current: document.createElement('div') };
|
||||
const { result } = renderHook(() => useIntersectionObserver(ref));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/** Options for useIntersectionObserver */
|
||||
export interface UseIntersectionObserverOptions extends IntersectionObserverInit {
|
||||
/** Whether to disconnect after the first intersection */
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook that observes if an element is intersecting with its root.
|
||||
*
|
||||
* @param ref - A React ref to the element to observe.
|
||||
* @param options - IntersectionObserver configuration.
|
||||
* @returns Boolean indicating if the element is currently intersecting.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const MyComponent = () => {
|
||||
* const ref = useRef<HTMLDivElement>(null);
|
||||
* const isVisible = useIntersectionObserver(ref);
|
||||
*
|
||||
* return <div ref={ref}>{isVisible ? 'I am visible!' : 'Scroll down...'}</div>;
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export const useIntersectionObserver = <T extends HTMLElement>(
|
||||
ref: React.RefObject<T | null>,
|
||||
{ once = true, ...options }: UseIntersectionObserverOptions = {}
|
||||
): boolean => {
|
||||
const [isIntersecting, setIntersecting] = useState(false);
|
||||
const { root, rootMargin, threshold } = options;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
setIntersecting(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observeTarget = ref.current;
|
||||
if (!observeTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIntersecting(entry.isIntersecting);
|
||||
if (entry.isIntersecting && once) {
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ root, rootMargin, threshold }
|
||||
);
|
||||
|
||||
observer.observe(observeTarget);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [ref, once, root, rootMargin, threshold]);
|
||||
|
||||
return isIntersecting;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useLocalStorage';
|
||||
@@ -0,0 +1,65 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
import { createLocalStorage } from '../../window/localStorage';
|
||||
|
||||
describe('lib/react/useLocalStorage', () => {
|
||||
const KEY = 'ermnvldmr/stl/lib/react/useLocalStorage/test';
|
||||
const DEFAULT_VALUE = 'default';
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return default value initially', () => {
|
||||
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
|
||||
const { result } = renderHook(() => useLocalStorage(manager));
|
||||
expect(result.current[0]).toBe(DEFAULT_VALUE);
|
||||
});
|
||||
|
||||
it('should update state when setter is called', () => {
|
||||
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
|
||||
const { result } = renderHook(() => useLocalStorage(manager));
|
||||
|
||||
act(() => {
|
||||
result.current[1]('new value');
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe('new value');
|
||||
expect(window.localStorage.getItem(KEY)).toBe(JSON.stringify('new value'));
|
||||
});
|
||||
|
||||
it('should sync across tabs via storage event', () => {
|
||||
const manager = createLocalStorage(KEY, DEFAULT_VALUE);
|
||||
const { result } = renderHook(() => useLocalStorage(manager));
|
||||
|
||||
act(() => {
|
||||
// Simulate external localStorage update
|
||||
window.localStorage.setItem(KEY, JSON.stringify('external update'));
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: KEY,
|
||||
newValue: JSON.stringify('external update'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe('external update');
|
||||
});
|
||||
|
||||
it('should support functional updates', () => {
|
||||
const manager = createLocalStorage(KEY, 0);
|
||||
const { result } = renderHook(() => useLocalStorage(manager));
|
||||
|
||||
act(() => {
|
||||
result.current[1]((prev) => prev + 1);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
import type { StorageManager } from '../../window/localStorage';
|
||||
|
||||
/**
|
||||
* A React hook that synchronizes state with a StorageManager.
|
||||
*
|
||||
* It provides reactivity across the component lifecycle and synchronizes
|
||||
* values across multiple browser tabs using the 'storage' event.
|
||||
*
|
||||
* @template T - The type of the stored value.
|
||||
* @param manager - The StorageManager instance returned by createLocalStorage.
|
||||
* @returns A tuple containing the current value and a setter function.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [theme, setTheme] = useLocalStorage(themeStorage);
|
||||
* ```
|
||||
*/
|
||||
export function useLocalStorage<T>(
|
||||
manager: StorageManager<T>
|
||||
): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void) => {
|
||||
const handleStorageChange = (event: StorageEvent): void => {
|
||||
if (event.key === manager.key) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorageChange);
|
||||
};
|
||||
},
|
||||
[manager.key]
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => manager.get(), [manager]);
|
||||
|
||||
const value = useSyncExternalStore(subscribe, getSnapshot, () => {
|
||||
// SSR snapshot: createLocalStorage returns a no-op manager in Node context,
|
||||
// so manager.get() safely returns defaultValue without accessing localStorage.
|
||||
return manager.get();
|
||||
});
|
||||
|
||||
const setValue = useCallback(
|
||||
(nextValue: T | ((prev: T) => T)) => {
|
||||
manager.set(nextValue);
|
||||
},
|
||||
[manager]
|
||||
);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
Loaded 100 of 777 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user