Skip to content

Commit 69d331e

Browse files
authored
fix(docs): preserve css properties outside of production builds (#6579)
* fix(docs): preserve css properties across `stencil docs` * chore:
1 parent a50de3f commit 69d331e

3 files changed

Lines changed: 222 additions & 0 deletions

File tree

src/compiler/docs/readme/output-docs.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export const generateReadme = async (
6666
: // Default case: writing to srcDir, so use the provided user content.
6767
userContent;
6868

69+
// CSS Custom Properties preservation is now handled centrally in outputDocs
6970
const readmeContent = generateMarkdown(currentReadmeContent, docsData, cmps, readmeOutput, config);
7071

7172
const results = await compilerCtx.fs.writeFile(readmeOutputPath, readmeContent);
@@ -130,3 +131,76 @@ const getDocsDeprecation = (cmp: d.JsonDocsComponent) => {
130131
const getDefaultReadme = (docsData: d.JsonDocsComponent) => {
131132
return [`# ${docsData.tag}`, '', '', ''].join('\n');
132133
};
134+
135+
/**
136+
* Extract the existing CSS Custom Properties section from a README file.
137+
* This is used to preserve CSS props documentation when running `stencil docs`
138+
* without building styles.
139+
*
140+
* @param compilerCtx the current compiler context
141+
* @param readmePath the path to the README file to read
142+
* @returns array of CSS custom properties styles, or undefined if none found
143+
*/
144+
export const extractExistingCssProps = async (
145+
compilerCtx: d.CompilerCtx,
146+
readmePath: string,
147+
): Promise<d.JsonDocsStyle[] | undefined> => {
148+
try {
149+
const existingContent = await compilerCtx.fs.readFile(readmePath);
150+
151+
// Find the CSS Custom Properties section
152+
const cssPropsSectionMatch = existingContent.match(
153+
/## CSS Custom Properties\s*\n\s*\n([\s\S]*?)(?=\n##|\n-{4,}|$)/,
154+
);
155+
if (!cssPropsSectionMatch) {
156+
return undefined;
157+
}
158+
159+
const cssPropsSection = cssPropsSectionMatch[1];
160+
const styles: d.JsonDocsStyle[] = [];
161+
162+
// Parse the markdown table to extract CSS custom properties
163+
// Table format:
164+
// | Name | Description |
165+
// | ---- | ----------- |
166+
// | `--prop-name` | Description text |
167+
const lines = cssPropsSection.split('\n');
168+
let inTable = false;
169+
170+
for (const line of lines) {
171+
const trimmedLine = line.trim();
172+
173+
// Skip header and separator rows
174+
if (trimmedLine.startsWith('| Name') || trimmedLine.startsWith('| ---')) {
175+
inTable = true;
176+
continue;
177+
}
178+
179+
// Parse table rows
180+
if (inTable && trimmedLine.startsWith('|')) {
181+
const parts = trimmedLine
182+
.split('|')
183+
.map((p) => p.trim())
184+
.filter((p) => p);
185+
if (parts.length >= 2) {
186+
// Extract the CSS variable name (remove backticks)
187+
const name = parts[0].replace(/`/g, '').trim();
188+
const docs = parts[1].trim();
189+
190+
if (name.startsWith('--')) {
191+
styles.push({
192+
name,
193+
docs,
194+
annotation: 'prop',
195+
mode: undefined,
196+
});
197+
}
198+
}
199+
}
200+
}
201+
202+
return styles.length > 0 ? styles : undefined;
203+
} catch (e) {
204+
return undefined;
205+
}
206+
};
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type * as d from '../../../declarations';
2+
import { generateMarkdown } from '../readme/output-docs';
3+
4+
describe('css-props to markdown', () => {
5+
describe('generateMarkdown', () => {
6+
const mockReadmeOutput: d.OutputTargetDocsReadme = {
7+
type: 'docs-readme',
8+
footer: '*Built with StencilJS*',
9+
};
10+
11+
const mockComponent: d.JsonDocsComponent = {
12+
tag: 'my-component',
13+
filePath: 'src/components/my-component/my-component.tsx',
14+
fileName: 'my-component.tsx',
15+
dirPath: 'src/components/my-component',
16+
readmePath: 'src/components/my-component/readme.md',
17+
usagesDir: 'src/components/my-component/usage',
18+
encapsulation: 'shadow',
19+
docs: '',
20+
docsTags: [],
21+
usage: {},
22+
props: [],
23+
methods: [],
24+
events: [],
25+
listeners: [],
26+
styles: [],
27+
slots: [],
28+
parts: [],
29+
dependents: [],
30+
dependencies: [],
31+
dependencyGraph: {},
32+
customStates: [],
33+
readme: '',
34+
};
35+
36+
it.each([
37+
{
38+
name: 'component styles when available',
39+
componentStyles: [
40+
{ name: '--background', docs: 'Background color', annotation: 'prop' as const, mode: undefined },
41+
{ name: '--color', docs: 'Text color', annotation: 'prop' as const, mode: undefined },
42+
],
43+
shouldContain: ['## CSS Custom Properties', '`--background`', 'Background color', '`--color`', 'Text color'],
44+
shouldNotContain: [],
45+
},
46+
{
47+
name: 'preserved CSS props (already in component.styles)',
48+
componentStyles: [
49+
{
50+
name: '--bg',
51+
docs: 'Defaults to var(--nano-color-blue-cerulean-1000);',
52+
annotation: 'prop' as const,
53+
mode: undefined,
54+
},
55+
{ name: '--text-color', docs: 'Text color of the component', annotation: 'prop' as const, mode: undefined },
56+
],
57+
shouldContain: ['## CSS Custom Properties', '`--bg`', 'Defaults to var(--nano-color-blue-cerulean-1000);'],
58+
shouldNotContain: [],
59+
},
60+
{
61+
name: 'no CSS section when styles are empty',
62+
componentStyles: [],
63+
shouldContain: [],
64+
shouldNotContain: ['## CSS Custom Properties'],
65+
},
66+
{
67+
name: 'updated component styles',
68+
componentStyles: [
69+
{ name: '--new-prop', docs: 'New property from build', annotation: 'prop' as const, mode: undefined },
70+
],
71+
shouldContain: ['`--new-prop`', 'New property from build'],
72+
shouldNotContain: [],
73+
},
74+
])('should use $name', ({ componentStyles, shouldContain, shouldNotContain }) => {
75+
const component: d.JsonDocsComponent = {
76+
...mockComponent,
77+
styles: componentStyles,
78+
};
79+
80+
const markdown = generateMarkdown('# my-component', component, [], mockReadmeOutput);
81+
82+
shouldContain.forEach((expected) => {
83+
expect(markdown).toContain(expected);
84+
});
85+
86+
shouldNotContain.forEach((unexpected) => {
87+
expect(markdown).not.toContain(unexpected);
88+
});
89+
});
90+
91+
it('should escape special characters in CSS prop descriptions', () => {
92+
const component: d.JsonDocsComponent = {
93+
...mockComponent,
94+
styles: [
95+
{
96+
name: '--bg',
97+
docs: 'Defaults to var(--nano-color-blue-cerulean-1000); with | pipes',
98+
annotation: 'prop',
99+
mode: undefined,
100+
},
101+
],
102+
};
103+
104+
const markdown = generateMarkdown('# my-component', component, [], mockReadmeOutput);
105+
106+
// Pipe characters are escaped in markdown tables
107+
expect(markdown).toContain('Defaults to var(--nano-color-blue-cerulean-1000); with \\| pipes');
108+
});
109+
});
110+
});

src/compiler/output-targets/output-docs.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
isOutputTargetDocsJson,
55
isOutputTargetDocsReadme,
66
isOutputTargetDocsVscode,
7+
join,
8+
normalizePath,
79
} from '@utils';
810

911
import type * as d from '../../declarations';
@@ -12,6 +14,7 @@ import { generateCustomElementsManifestDocs } from '../docs/cem';
1214
import { generateDocData } from '../docs/generate-doc-data';
1315
import { generateJsonDocs } from '../docs/json';
1416
import { generateReadmeDocs } from '../docs/readme';
17+
import { extractExistingCssProps } from '../docs/readme/output-docs';
1518
import { generateVscodeDocs } from '../docs/vscode';
1619

1720
/**
@@ -46,6 +49,41 @@ export const outputDocs = async (
4649

4750
const docsData = await generateDocData(config, compilerCtx, buildCtx);
4851

52+
// If we're in docs-only mode (not a full build), preserve CSS Custom Properties
53+
// from existing README files for components with empty styles.
54+
// We detect docs-only mode by checking if ALL output targets are docs targets.
55+
const isDocsOnlyMode = config.outputTargets.every(
56+
(target) =>
57+
target.type === 'docs-readme' ||
58+
target.type === 'docs-json' ||
59+
target.type === 'docs-custom' ||
60+
target.type === 'docs-vscode' ||
61+
target.type === 'docs-custom-elements-manifest',
62+
);
63+
64+
if (isDocsOnlyMode) {
65+
// Preserve CSS props for components with empty styles
66+
await Promise.all(
67+
docsData.components.map(async (component) => {
68+
if (component.styles.length === 0) {
69+
// Find the README output target to get the correct path
70+
const readmeTarget = docsOutputTargets.find(isOutputTargetDocsReadme) as d.OutputTargetDocsReadme | undefined;
71+
const readmeDir = readmeTarget?.dir || config.srcDir;
72+
const readmePath =
73+
normalizePath(readmeDir) === normalizePath(config.srcDir)
74+
? component.readmePath
75+
: join(readmeDir, component.readmePath.replace(config.srcDir, ''));
76+
77+
const existingCssProps = await extractExistingCssProps(compilerCtx, readmePath);
78+
if (existingCssProps) {
79+
// Update component styles with preserved props
80+
component.styles = existingCssProps;
81+
}
82+
}
83+
}),
84+
);
85+
}
86+
4987
await Promise.all([
5088
generateReadmeDocs(config, compilerCtx, docsData, docsOutputTargets),
5189
generateJsonDocs(config, compilerCtx, docsData, docsOutputTargets),

0 commit comments

Comments
 (0)