build(#10336): Storybook & Chromatic & msw (#10365)

* build(#10336): init

* fix(#10336): invalid name conversion

* build(#10336): load locales and vite config

* refactor(#10336): remove unused imports

* build(#10336): separate definitions and generated codes

* refactor(#10336): remove hatches

* refactor(#10336): module semantics

* refactor(#10336): remove unused common preferences

* fix: typo

* build(#10336): mock assets

* build(#10336): impl `SatisfiesExpression`

* build(#10336): control themes

* refactor(#10336): semantics

* build(#10336): make .storybook as an individual TypeScript project

* style(#10336): use single quote

* build(#10336): avoid intrinsic component names

* chore: suppress linter

* style: typing

* build(#10336): update dependencies

* docs: note about Storybook

* build(#10336): sync

* build(#10336): full reload server on change

* chore: use defaultStore instead

* build(#10336): show popups on Story

* refactor(#10336): remove redundant div

* docs: fix

* build(#10336): interactions

* build(#10336): add an interaction test for `<MkA/>`

* build(#10336): bump storybook

* docs(#10336): mention to pre-build misskey-js

* build(#10336): write stories for `MkAcct`

* build(#10336): write stories for `MkAd`

* build(#10336): fix missing type definition

* build(#10336): use `toHaveTextContent`

* build(#10336): write some stories

* build(#10336): hide internal args

* build(#10336): generate `components/global` stories only

* build(#10336): write stories for `MkMisskeyFlavoredMarkdown`

* fix: conflict errors

* build(#10336): subcomponents on sidebar

* refactor: restore `SatisfiesExpression`

* docs(#10336): note development status

* build(#10336): use chokidar-cli

* docs(#10336): note chokidar-cli mode

* chore(#10336): untrack generated stories files

* fix: pointer handling

* build(#10336): finalize

* chore: add static option to `MkLoading`

* refactor(#10336): bind to local args

* fix: missing case

* revert: restore `SatisfiesExpression`

This reverts commit f246699f38.

* build(#10336): make storybook buildable

* build(#10336): staticify assets

* build(#10336): staticified directory structure

* build(#10336): normalize path for Windows

* ci(#10336): create actions

* build(#10336): ignore tsc errors

* build(#10336): ignore tsc errors

* build(#10336): missing dependencies

* build(#10336): missing dependencies

* build(#10336): use fast-glob

* fix: invalid lockfile

* ci(#10336): increase heap size

* build(#10336): use unpkg for storybook tabler icons

* build(#10336): use unpkg for storybook twemojis

* build(#10336): disable `ProfilePageCat`

* build(#10336): blur `MkA` before interaction ends

* ci(#10336): stabilize

* ci(#10336): fetch-depth

* build(#10336): isChromatic

* ci(#10336): notify on changes

* ci(#10336): fix typo

* ci(#10336): missing working directory

* ci(#10336): skip build

* ci(#10336): fix path

* build(#10336): fails on Windows

* build(#10336): available on Windows

* ci(#10336): disable animation on chromatic

* ci(#10336): add static option to `PageHeader.tabs`

* chore: void

* ci(#10336): change parameters

* docs(#10336): update CONTRIBUTING

* docs(#10336): note about meta overriding and etc.

* ci(#10336): use Chromatic for checks

* ci(#10336): use `pull_request` instead of `pull_request_target` for now

* ci(#10336): use `exitOnceUploaded`

* ci(#10336): reuse built storybook

* ci(#10336): back to `pull_request_target`

* chore: unused dependencies

* style(#10336): reduce prettier indents

* style: note about `TSSatisfiesExpression`
This commit is contained in:
Acid Chicken (硫酸鶏)
2023-04-04 09:38:34 +09:00
committed by GitHub
parent 8a0201fe9c
commit 38d0b62167
59 changed files with 7708 additions and 365 deletions

View File

@@ -0,0 +1,9 @@
# (cd path/to/frontend; pnpm tsc -p .storybook)
# (cd path/to/frontend; node .storybook/generate.js)
/generate.js
# (cd path/to/frontend; node .storybook/preload-locale.js)
/preload-locale.js
/locale.ts
# (cd path/to/frontend; node .storybook/preload-theme.js)
/preload-theme.js
/themes.ts

View File

@@ -0,0 +1,54 @@
import type { entities } from 'misskey-js'
export const userDetailed = {
id: 'someuserid',
username: 'miskist',
host: 'misskey-hub.net',
name: 'Misskey User',
onlineStatus: 'unknown',
avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
emojis: [],
bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog',
bannerColor: '#000000',
bannerUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
birthday: '2014-06-20',
createdAt: '2016-12-28T22:49:51.000Z',
description: 'I am a cool user!',
ffVisibility: 'public',
fields: [
{
name: 'Website',
value: 'https://misskey-hub.net',
},
],
followersCount: 1024,
followingCount: 16,
hasPendingFollowRequestFromYou: false,
hasPendingFollowRequestToYou: false,
isAdmin: false,
isBlocked: false,
isBlocking: false,
isBot: false,
isCat: false,
isFollowed: false,
isFollowing: false,
isLocked: false,
isModerator: false,
isMuted: false,
isSilenced: false,
isSuspended: false,
lang: 'en',
location: 'Fediverse',
notesCount: 65536,
pinnedNoteIds: [],
pinnedNotes: [],
pinnedPage: null,
pinnedPageId: null,
publicReactions: false,
securityKeys: false,
twoFactorEnabled: false,
updatedAt: null,
uri: null,
url: null,
} satisfies entities.UserDetailed

View File

@@ -0,0 +1,406 @@
import { existsSync, readFileSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { basename, dirname } from 'node:path/posix';
import { GENERATOR, type State, generate } from 'astring';
import type * as estree from 'estree';
import glob from 'fast-glob';
import { format } from 'prettier';
interface SatisfiesExpression extends estree.BaseExpression {
type: 'SatisfiesExpression';
expression: estree.Expression;
reference: estree.Identifier;
}
const generator = {
...GENERATOR,
SatisfiesExpression(node: SatisfiesExpression, state: State) {
switch (node.expression.type) {
case 'ArrowFunctionExpression': {
state.write('(');
this[node.expression.type](node.expression, state);
state.write(')');
break;
}
default: {
// @ts-ignore
this[node.expression.type](node.expression, state);
break;
}
}
state.write(' satisfies ', node as unknown as estree.Expression);
this[node.reference.type](node.reference, state);
},
};
type SplitCamel<
T extends string,
YC extends string = '',
YN extends readonly string[] = []
> = T extends `${infer XH}${infer XR}`
? XR extends ''
? [...YN, Uncapitalize<`${YC}${XH}`>]
: XH extends Uppercase<XH>
? SplitCamel<XR, Lowercase<XH>, [...YN, YC]>
: SplitCamel<XR, `${YC}${XH}`, YN>
: YN;
// @ts-ignore
type SplitKebab<T extends string> = T extends `${infer XH}-${infer XR}`
? [XH, ...SplitKebab<XR>]
: [T];
type ToKebab<T extends readonly string[]> = T extends readonly [
infer XO extends string
]
? XO
: T extends readonly [
infer XH extends string,
...infer XR extends readonly string[]
]
? `${XH}${XR extends readonly string[] ? `-${ToKebab<XR>}` : ''}`
: '';
// @ts-ignore
type ToPascal<T extends readonly string[]> = T extends readonly [
infer XH extends string,
...infer XR extends readonly string[]
]
? `${Capitalize<XH>}${ToPascal<XR>}`
: '';
function h<T extends estree.Node>(
component: T['type'],
props: Omit<T, 'type'>
): T {
const type = component.replace(/(?:^|-)([a-z])/g, (_, c) => c.toUpperCase());
return Object.assign(props || {}, { type }) as T;
}
declare global {
namespace JSX {
type Element = estree.Node;
type ElementClass = never;
type ElementAttributesProperty = never;
type ElementChildrenAttribute = never;
type IntrinsicAttributes = never;
type IntrinsicClassAttributes<T> = never;
type IntrinsicElements = {
[T in keyof typeof generator as ToKebab<SplitCamel<Uncapitalize<T>>>]: {
[K in keyof Omit<
Parameters<(typeof generator)[T]>[0],
'type'
>]?: Parameters<(typeof generator)[T]>[0][K];
};
};
}
}
function toStories(component: string): string {
const msw = `${component.slice(0, -'.vue'.length)}.msw`;
const implStories = `${component.slice(0, -'.vue'.length)}.stories.impl`;
const metaStories = `${component.slice(0, -'.vue'.length)}.stories.meta`;
const hasMsw = existsSync(`${msw}.ts`);
const hasImplStories = existsSync(`${implStories}.ts`);
const hasMetaStories = existsSync(`${metaStories}.ts`);
const base = basename(component);
const dir = dirname(component);
const literal =
<literal
value={component
.slice('src/'.length, -'.vue'.length)
.replace(/\./g, '/')}
/> as estree.Literal;
const identifier =
<identifier
name={base
.slice(0, -'.vue'.length)
.replace(/[-.]|^(?=\d)/g, '_')
.replace(/(?<=^[^A-Z_]*$)/, '_')}
/> as estree.Identifier;
const parameters = (
<object-expression
properties={[
<property
key={<identifier name='layout' /> as estree.Identifier}
value={<literal value={`${dir}/`.startsWith('src/pages/') ? 'fullscreen' : 'centered'}/> as estree.Literal}
kind={'init' as const}
/> as estree.Property,
...(hasMsw
? [
<property
key={<identifier name='msw' /> as estree.Identifier}
value={<identifier name='msw' /> as estree.Identifier}
kind={'init' as const}
shorthand
/> as estree.Property,
]
: []),
]}
/>
) as estree.ObjectExpression;
const program = (
<program
body={[
<import-declaration
source={<literal value='@storybook/vue3' /> as estree.Literal}
specifiers={[
<import-specifier
local={<identifier name='Meta' /> as estree.Identifier}
imported={<identifier name='Meta' /> as estree.Identifier}
/> as estree.ImportSpecifier,
...(hasImplStories
? []
: [
<import-specifier
local={<identifier name='StoryObj' /> as estree.Identifier}
imported={<identifier name='StoryObj' /> as estree.Identifier}
/> as estree.ImportSpecifier,
]),
]}
/> as estree.ImportDeclaration,
...(hasMsw
? [
<import-declaration
source={<literal value={`./${basename(msw)}`} /> as estree.Literal}
specifiers={[
<import-namespace-specifier
local={<identifier name='msw' /> as estree.Identifier}
/> as estree.ImportNamespaceSpecifier,
]}
/> as estree.ImportDeclaration,
]
: []),
...(hasImplStories
? []
: [
<import-declaration
source={<literal value={`./${base}`} /> as estree.Literal}
specifiers={[
<import-default-specifier local={identifier} /> as estree.ImportDefaultSpecifier,
]}
/> as estree.ImportDeclaration,
]),
...(hasMetaStories
? [
<import-declaration
source={<literal value={`./${basename(metaStories)}`} /> as estree.Literal}
specifiers={[
<import-namespace-specifier
local={<identifier name='storiesMeta' /> as estree.Identifier}
/> as estree.ImportNamespaceSpecifier,
]}
/> as estree.ImportDeclaration,
]
: []),
<variable-declaration
kind={'const' as const}
declarations={[
<variable-declarator
id={<identifier name='meta' /> as estree.Identifier}
init={
<satisfies-expression
expression={
<object-expression
properties={[
<property
key={<identifier name='title' /> as estree.Identifier}
value={literal}
kind={'init' as const}
/> as estree.Property,
<property
key={<identifier name='component' /> as estree.Identifier}
value={identifier}
kind={'init' as const}
/> as estree.Property,
...(hasMetaStories
? [
<spread-element
argument={<identifier name='storiesMeta' /> as estree.Identifier}
/> as estree.SpreadElement,
]
: [])
]}
/> as estree.ObjectExpression
}
reference={<identifier name={`Meta<typeof ${identifier.name}>`} /> as estree.Identifier}
/> as estree.Expression
}
/> as estree.VariableDeclarator,
]}
/> as estree.VariableDeclaration,
...(hasImplStories
? []
: [
<export-named-declaration
declaration={
<variable-declaration
kind={'const' as const}
declarations={[
<variable-declarator
id={<identifier name='Default' /> as estree.Identifier}
init={
<satisfies-expression
expression={
<object-expression
properties={[
<property
key={<identifier name='render' /> as estree.Identifier}
value={
<function-expression
params={[
<identifier name='args' /> as estree.Identifier,
]}
body={
<block-statement
body={[
<return-statement
argument={
<object-expression
properties={[
<property
key={<identifier name='components' /> as estree.Identifier}
value={
<object-expression
properties={[
<property key={identifier} value={identifier} kind={'init' as const} shorthand /> as estree.Property,
]}
/> as estree.ObjectExpression
}
kind={'init' as const}
/> as estree.Property,
<property
key={<identifier name='setup' /> as estree.Identifier}
value={
<function-expression
params={[]}
body={
<block-statement
body={[
<return-statement
argument={
<object-expression
properties={[
<property
key={<identifier name='args' /> as estree.Identifier}
value={<identifier name='args' /> as estree.Identifier}
kind={'init' as const}
shorthand
/> as estree.Property,
]}
/> as estree.ObjectExpression
}
/> as estree.ReturnStatement,
]}
/> as estree.BlockStatement
}
/> as estree.FunctionExpression
}
method
kind={'init' as const}
/> as estree.Property,
<property
key={<identifier name='computed' /> as estree.Identifier}
value={
<object-expression
properties={[
<property
key={<identifier name='props' /> as estree.Identifier}
value={
<function-expression
params={[]}
body={
<block-statement
body={[
<return-statement
argument={
<object-expression
properties={[
<spread-element
argument={
<member-expression
object={<this-expression /> as estree.ThisExpression}
property={<identifier name='args' /> as estree.Identifier}
/> as estree.MemberExpression
}
/> as estree.SpreadElement,
]}
/> as estree.ObjectExpression
}
/> as estree.ReturnStatement,
]}
/> as estree.BlockStatement
}
/> as estree.FunctionExpression
}
method
kind={'init' as const}
/> as estree.Property,
]}
/> as estree.ObjectExpression
}
kind={'init' as const}
/> as estree.Property,
<property
key={<identifier name='template' /> as estree.Identifier}
value={<literal value={`<${identifier.name} v-bind="props" />`} /> as estree.Literal}
kind={'init' as const}
/> as estree.Property,
]}
/> as estree.ObjectExpression
}
/> as estree.ReturnStatement,
]}
/> as estree.BlockStatement
}
/> as estree.FunctionExpression
}
method
kind={'init' as const}
/> as estree.Property,
<property
key={<identifier name='parameters' /> as estree.Identifier}
value={parameters}
kind={'init' as const}
/> as estree.Property,
]}
/> as estree.ObjectExpression
}
reference={<identifier name={`StoryObj<typeof ${identifier.name}>`} /> as estree.Identifier}
/> as estree.Expression
}
/> as estree.VariableDeclarator,
]}
/> as estree.VariableDeclaration
}
/> as estree.ExportNamedDeclaration,
]),
<export-default-declaration
declaration={(<identifier name='meta' />) as estree.Identifier}
/> as estree.ExportDefaultDeclaration,
]}
/>
) as estree.Program;
return format(
'/* eslint-disable @typescript-eslint/explicit-function-return-type */\n' +
'/* eslint-disable import/no-default-export */\n' +
generate(program, { generator }) +
(hasImplStories ? readFileSync(`${implStories}.ts`, 'utf-8') : ''),
{
parser: 'babel-ts',
singleQuote: true,
useTabs: true,
}
);
}
// promisify(glob)('src/{components,pages,ui,widgets}/**/*.vue').then(
glob('src/components/global/**/*.vue').then(
(components) =>
Promise.all(
components.map((component) => {
const stories = component.replace(/\.vue$/, '.stories.ts');
return writeFile(stories, toStories(component));
})
)
);

View File

@@ -0,0 +1,35 @@
import { resolve } from 'node:path';
import type { StorybookConfig } from '@storybook/vue3-vite';
import { mergeConfig } from 'vite';
const config = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-links',
'@storybook/addon-storysource',
resolve(__dirname, '../node_modules/storybook-addon-misskey-theme'),
],
framework: {
name: '@storybook/vue3-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
core: {
disableTelemetry: true,
},
async viteFinal(config, options) {
return mergeConfig(config, {
build: {
target: [
'chrome108',
'firefox109',
'safari16',
],
},
});
},
} satisfies StorybookConfig;
export default config;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,16 @@
import { type SharedOptions, rest } from 'msw';
export const onUnhandledRequest = ((req, print) => {
if (req.url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(req.url.pathname)) {
return
}
print.warning()
}) satisfies SharedOptions['onUnhandledRequest'];
export const commonHandlers = [
rest.get('/twemoji/:codepoints.svg', async (req, res, ctx) => {
const { codepoints } = req.params;
const value = await fetch(`https://unpkg.com/@discordapp/twemoji@14.1.2/dist/svg/${codepoints}.svg`).then((response) => response.blob());
return res(ctx.set('Content-Type', 'image/svg+xml'), ctx.body(value));
}),
];

View File

@@ -0,0 +1,9 @@
import { writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import * as locales from '../../../locales';
writeFile(
resolve(__dirname, 'locale.ts'),
`export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`,
'utf8',
)

View File

@@ -0,0 +1,39 @@
import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import * as JSON5 from 'json5';
const keys = [
'_dark',
'_light',
'l-light',
'l-coffee',
'l-apricot',
'l-rainy',
'l-botanical',
'l-vivid',
'l-cherry',
'l-sushi',
'l-u0',
'd-dark',
'd-persimmon',
'd-astro',
'd-future',
'd-botanical',
'd-green-lime',
'd-green-orange',
'd-cherry',
'd-ice',
'd-u0',
]
Promise.all(keys.map((key) => readFile(resolve(__dirname, `../src/themes/${key}.json5`), 'utf8'))).then((sources) => {
writeFile(
resolve(__dirname, './themes.ts'),
`export default ${JSON.stringify(
Object.fromEntries(sources.map((source, i) => [keys[i], JSON5.parse(source)])),
undefined,
2,
)} as const;`,
'utf8'
);
});

View File

@@ -0,0 +1,4 @@
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@2.12.0/tabler-icons.min.css">
<script>
window.global = window;
</script>

View File

@@ -0,0 +1,113 @@
import { addons } from '@storybook/addons';
import { FORCE_REMOUNT } from '@storybook/core-events';
import { type Preview, setup } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic';
import { initialize, mswDecorator } from 'msw-storybook-addon';
import locale from './locale';
import { commonHandlers, onUnhandledRequest } from './mocks';
import themes from './themes';
import '../src/style.scss';
const appInitialized = Symbol();
let moduleInitialized = false;
let unobserve = () => {};
let misskeyOS = null;
function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme']) {
unobserve();
const theme = themes[document.documentElement.dataset.misskeyTheme];
if (theme) {
applyTheme(themes[document.documentElement.dataset.misskeyTheme]);
} else if (isChromatic()) {
applyTheme(themes['l-light']);
}
const observer = new MutationObserver((entries) => {
for (const entry of entries) {
if (entry.attributeName === 'data-misskey-theme') {
const target = entry.target as HTMLElement;
const theme = themes[target.dataset.misskeyTheme];
if (theme) {
applyTheme(themes[target.dataset.misskeyTheme]);
} else {
target.removeAttribute('style');
}
}
}
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-misskey-theme'],
});
unobserve = () => observer.disconnect();
}
initialize({
onUnhandledRequest,
});
localStorage.setItem("locale", JSON.stringify(locale));
queueMicrotask(() => {
Promise.all([
import('../src/components'),
import('../src/directives'),
import('../src/widgets'),
import('../src/scripts/theme'),
import('../src/store'),
import('../src/os'),
]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { defaultStore }, os]) => {
setup((app) => {
moduleInitialized = true;
if (app[appInitialized]) {
return;
}
app[appInitialized] = true;
loadTheme(applyTheme);
components(app);
directives(app);
widgets(app);
misskeyOS = os;
if (isChromatic()) {
defaultStore.set('animation', false);
}
});
});
});
const preview = {
decorators: [
(Story, context) => {
const story = Story();
if (!moduleInitialized) {
const channel = addons.getChannel();
(globalThis.requestIdleCallback || setTimeout)(() => {
channel.emit(FORCE_REMOUNT, { storyId: context.id });
});
}
return story;
},
mswDecorator,
(Story, context) => {
return {
setup() {
return {
context,
popups: misskeyOS.popups,
};
},
template:
'<component :is="popup.component" v-for="popup in popups" :key="popup.id" v-bind="popup.props" v-on="popup.events"/>' +
'<story />',
};
},
],
parameters: {
controls: {
exclude: /^__/,
},
msw: {
handlers: commonHandlers,
},
},
} satisfies Preview;
export default preview;

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"strict": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"checkJs": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react",
"jsxFactory": "h"
},
"files": ["./generate.tsx", "./preload-locale.ts", "./preload-theme.ts"]
}