@stats-forge/github-stats-forge-core 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +0 -10
  2. package/build/api/api-result.d.ts +6 -7
  3. package/build/api/api-result.d.ts.map +1 -1
  4. package/build/api/api-result.js +4 -5
  5. package/build/api/gist.d.ts +3 -10
  6. package/build/api/gist.d.ts.map +1 -1
  7. package/build/api/gist.js +4 -11
  8. package/build/api/index.d.ts +12 -12
  9. package/build/api/params.d.ts +11 -8
  10. package/build/api/params.d.ts.map +1 -1
  11. package/build/api/params.js +16 -14
  12. package/build/api/pin.d.ts +3 -19
  13. package/build/api/pin.d.ts.map +1 -1
  14. package/build/api/pin.js +6 -22
  15. package/build/api/stats.d.ts +10 -4
  16. package/build/api/stats.d.ts.map +1 -1
  17. package/build/api/stats.js +6 -32
  18. package/build/api/top-langs.d.ts +12 -6
  19. package/build/api/top-langs.d.ts.map +1 -1
  20. package/build/api/top-langs.js +6 -26
  21. package/build/api/wakatime.d.ts +13 -5
  22. package/build/api/wakatime.d.ts.map +1 -1
  23. package/build/api/wakatime.js +4 -21
  24. package/build/calculateRank.d.ts +0 -9
  25. package/build/calculateRank.d.ts.map +1 -1
  26. package/build/calculateRank.js +12 -17
  27. package/build/cards/gist.d.ts +2 -4
  28. package/build/cards/gist.d.ts.map +1 -1
  29. package/build/cards/gist.js +27 -36
  30. package/build/cards/options.d.ts +1 -1
  31. package/build/cards/repo.d.ts +2 -4
  32. package/build/cards/repo.d.ts.map +1 -1
  33. package/build/cards/repo.js +61 -93
  34. package/build/cards/stats.d.ts +3 -8
  35. package/build/cards/stats.d.ts.map +1 -1
  36. package/build/cards/stats.js +119 -165
  37. package/build/cards/top-languages.d.ts +5 -31
  38. package/build/cards/top-languages.d.ts.map +1 -1
  39. package/build/cards/top-languages.js +176 -384
  40. package/build/cards/wakatime.d.ts +4 -6
  41. package/build/cards/wakatime.d.ts.map +1 -1
  42. package/build/cards/wakatime.js +118 -185
  43. package/build/common/Card.d.ts +14 -62
  44. package/build/common/Card.d.ts.map +1 -1
  45. package/build/common/Card.js +97 -199
  46. package/build/common/I18n.d.ts +0 -6
  47. package/build/common/I18n.d.ts.map +1 -1
  48. package/build/common/I18n.js +3 -6
  49. package/build/common/color.d.ts +4 -17
  50. package/build/common/color.d.ts.map +1 -1
  51. package/build/common/color.js +67 -49
  52. package/build/common/config.d.ts +4 -14
  53. package/build/common/config.d.ts.map +1 -1
  54. package/build/common/config.js +3 -16
  55. package/build/common/constants.d.ts +4 -2
  56. package/build/common/constants.d.ts.map +1 -1
  57. package/build/common/constants.js +3 -1
  58. package/build/common/date.d.ts +0 -2
  59. package/build/common/date.d.ts.map +1 -1
  60. package/build/common/date.js +0 -2
  61. package/build/common/emojiMap.d.ts +8 -0
  62. package/build/common/emojiMap.d.ts.map +1 -0
  63. package/build/common/emojiMap.js +10 -0
  64. package/build/common/emojiMap.json +1915 -0
  65. package/build/common/error.d.ts +1 -10
  66. package/build/common/error.d.ts.map +1 -1
  67. package/build/common/error.js +1 -10
  68. package/build/common/fmt.d.ts +3 -15
  69. package/build/common/fmt.d.ts.map +1 -1
  70. package/build/common/fmt.js +7 -35
  71. package/build/common/http.d.ts +2 -7
  72. package/build/common/http.d.ts.map +1 -1
  73. package/build/common/http.js +8 -17
  74. package/build/common/icons.d.ts +18 -20
  75. package/build/common/icons.d.ts.map +1 -1
  76. package/build/common/icons.js +103 -67
  77. package/build/common/languageColors.d.ts +0 -1
  78. package/build/common/languageColors.d.ts.map +1 -1
  79. package/build/common/languageColors.js +1 -4
  80. package/build/common/languageColors.json +18 -2
  81. package/build/common/ops.d.ts +3 -14
  82. package/build/common/ops.d.ts.map +1 -1
  83. package/build/common/ops.js +19 -30
  84. package/build/common/render.d.ts +28 -82
  85. package/build/common/render.d.ts.map +1 -1
  86. package/build/common/render.js +175 -211
  87. package/build/common/retryer.d.ts +2 -7
  88. package/build/common/retryer.d.ts.map +1 -1
  89. package/build/common/retryer.js +4 -11
  90. package/build/fetchers/gist.d.ts +2 -5
  91. package/build/fetchers/gist.d.ts.map +1 -1
  92. package/build/fetchers/gist.js +1 -5
  93. package/build/fetchers/index.d.ts +12 -12
  94. package/build/fetchers/repo.d.ts +2 -11
  95. package/build/fetchers/repo.d.ts.map +1 -1
  96. package/build/fetchers/repo.js +20 -33
  97. package/build/fetchers/stats.d.ts +2 -32
  98. package/build/fetchers/stats.d.ts.map +1 -1
  99. package/build/fetchers/stats.js +27 -86
  100. package/build/fetchers/top-languages.d.ts +2 -9
  101. package/build/fetchers/top-languages.d.ts.map +1 -1
  102. package/build/fetchers/top-languages.js +15 -31
  103. package/build/fetchers/types.d.ts +1 -1
  104. package/build/fetchers/wakatime.d.ts +2 -6
  105. package/build/fetchers/wakatime.d.ts.map +1 -1
  106. package/build/fetchers/wakatime.js +1 -5
  107. package/build/graphql/contributionsDocument.d.ts +3 -3
  108. package/build/graphql/contributionsDocument.d.ts.map +1 -1
  109. package/build/graphql/contributionsDocument.js +0 -1
  110. package/build/graphql/generated/gist.d.ts +1 -1
  111. package/build/graphql/generated/repo.d.ts +1 -1
  112. package/build/graphql/generated/stats.d.ts +3 -3
  113. package/build/graphql/generated/top-languages.d.ts +2 -2
  114. package/build/graphql/graphqlDocument.d.ts +0 -1
  115. package/build/graphql/graphqlDocument.d.ts.map +1 -1
  116. package/build/graphql/graphqlDocument.js +0 -1
  117. package/build/graphql/reposContributedToDocument.d.ts +4 -5
  118. package/build/graphql/reposContributedToDocument.d.ts.map +1 -1
  119. package/build/graphql/reposContributedToDocument.js +0 -2
  120. package/build/markup/css.d.ts +35 -0
  121. package/build/markup/css.d.ts.map +1 -0
  122. package/build/markup/css.js +64 -0
  123. package/build/markup/element.d.ts +41 -0
  124. package/build/markup/element.d.ts.map +1 -0
  125. package/build/markup/element.js +127 -0
  126. package/build/markup/html.d.ts +13 -0
  127. package/build/markup/html.d.ts.map +1 -0
  128. package/build/markup/html.js +27 -0
  129. package/build/markup/index.d.ts +9 -0
  130. package/build/markup/index.d.ts.map +1 -0
  131. package/build/markup/index.js +6 -0
  132. package/build/markup/serialize.d.ts +16 -0
  133. package/build/markup/serialize.d.ts.map +1 -0
  134. package/build/markup/serialize.js +17 -0
  135. package/build/themes/index.d.ts +332 -333
  136. package/build/themes/index.d.ts.map +1 -1
  137. package/build/themes/index.js +1 -1
  138. package/build/translations.d.ts +2 -6
  139. package/build/translations.d.ts.map +1 -1
  140. package/build/translations.js +621 -628
  141. package/package.json +17 -26
  142. package/src/api/api-result.ts +10 -9
  143. package/src/api/gist.ts +11 -18
  144. package/src/api/index.ts +12 -12
  145. package/src/api/params.ts +27 -21
  146. package/src/api/pin.ts +13 -28
  147. package/src/api/stats.ts +13 -39
  148. package/src/api/top-langs.ts +13 -33
  149. package/src/api/wakatime.ts +11 -28
  150. package/src/calculateRank.ts +12 -23
  151. package/src/cards/gist.ts +40 -43
  152. package/src/cards/options.ts +1 -1
  153. package/src/cards/repo.ts +97 -107
  154. package/src/cards/stats.ts +165 -184
  155. package/src/cards/top-languages.ts +297 -413
  156. package/src/cards/wakatime.ts +193 -221
  157. package/src/common/Card.ts +165 -217
  158. package/src/common/I18n.ts +3 -6
  159. package/src/common/color.ts +80 -57
  160. package/src/common/config.ts +5 -18
  161. package/src/common/constants.ts +5 -2
  162. package/src/common/date.ts +0 -2
  163. package/src/common/emojiMap.json +1915 -0
  164. package/src/common/emojiMap.ts +13 -0
  165. package/src/common/error.ts +2 -11
  166. package/src/common/fmt.ts +7 -44
  167. package/src/common/http.ts +9 -15
  168. package/src/common/icons.ts +117 -42
  169. package/src/common/languageColors.json +18 -2
  170. package/src/common/languageColors.ts +1 -4
  171. package/src/common/ops.ts +26 -34
  172. package/src/common/render.ts +279 -232
  173. package/src/common/retryer.ts +8 -15
  174. package/src/fetchers/gist.ts +8 -12
  175. package/src/fetchers/index.ts +12 -12
  176. package/src/fetchers/repo.ts +27 -40
  177. package/src/fetchers/stats.ts +44 -102
  178. package/src/fetchers/top-languages.ts +26 -40
  179. package/src/fetchers/types.ts +1 -1
  180. package/src/fetchers/wakatime.ts +5 -9
  181. package/src/graphql/contributionsDocument.ts +7 -5
  182. package/src/graphql/generated/gist.ts +1 -1
  183. package/src/graphql/generated/repo.ts +1 -1
  184. package/src/graphql/generated/stats.ts +2 -2
  185. package/src/graphql/generated/top-languages.ts +2 -2
  186. package/src/graphql/graphqlDocument.ts +0 -1
  187. package/src/graphql/reposContributedToDocument.ts +6 -7
  188. package/src/markup/css.ts +118 -0
  189. package/src/markup/element.ts +246 -0
  190. package/src/markup/html.ts +34 -0
  191. package/src/markup/index.ts +9 -0
  192. package/src/markup/serialize.ts +27 -0
  193. package/src/themes/index.ts +1 -1
  194. package/src/translations.ts +622 -629
  195. package/build/cards/index.d.ts +0 -11
  196. package/build/cards/index.d.ts.map +0 -1
  197. package/build/cards/index.js +0 -7
  198. package/build/common/html.d.ts +0 -8
  199. package/build/common/html.d.ts.map +0 -1
  200. package/build/common/html.js +0 -14
  201. package/build/index.d.ts +0 -15
  202. package/build/index.d.ts.map +0 -1
  203. package/build/index.js +0 -10
  204. package/src/_emoji-name-map.d.ts +0 -10
  205. package/src/_github-username-regex.d.ts +0 -4
  206. package/src/cards/index.ts +0 -21
  207. package/src/common/html.ts +0 -17
  208. package/src/index.ts +0 -18
@@ -1,30 +1,23 @@
1
- import type { CardConfig } from '../common/config.js';
2
- import { CardError, USER_NOT_FOUND } from '../common/error.js';
3
- import { wrapTextMultiline } from '../common/fmt.js';
4
- import { createGraphQLFetcher } from '../common/http.js';
5
- import { logger } from '../common/log.js';
6
- import { parseOwnerAffiliations } from '../common/ops.js';
7
- import { retryer } from '../common/retryer.js';
8
- import { TopLanguagesDocument } from '../graphql/generated/top-languages.js';
1
+ import type { CardConfig } from '../common/config.ts';
2
+ import { CardError, USER_NOT_FOUND } from '../common/error.ts';
3
+ import { createGraphQLFetcher } from '../common/http.ts';
4
+ import { logger } from '../common/log.ts';
5
+ import { parseOwnerAffiliations } from '../common/ops.ts';
6
+ import { wrapTextMultiline } from '../common/render.ts';
7
+ import { retryer } from '../common/retryer.ts';
8
+ import { TopLanguagesDocument } from '../graphql/generated/top-languages.ts';
9
9
  import type {
10
10
  TopLanguageFragment,
11
11
  TopLanguagesRepositoryFragment,
12
- } from '../graphql/generated/top-languages.js';
12
+ } from '../graphql/generated/top-languages.ts';
13
13
 
14
- import type { Lang, TopLangData } from './types.js';
14
+ import type { Lang, TopLangData } from './types.ts';
15
15
 
16
16
  const fetcher = createGraphQLFetcher(TopLanguagesDocument, 'token');
17
17
 
18
18
  /**
19
19
  * Fetch top languages for a given username.
20
20
  *
21
- * @param props Fetcher props.
22
- * @param props.username GitHub username.
23
- * @param props.exclude_repo List of repositories to exclude. Default: [].
24
- * @param props.size_weight Weightage to be given to size.
25
- * @param props.count_weight Weightage to be given to count.
26
- * @param props.ownerAffiliations The owner affiliations to filter by. Default: OWNER.
27
- * @param config Deployment config supplying the PAT pool.
28
21
  * @returns Top languages data.
29
22
  */
30
23
  const fetchTopLanguages = async (
@@ -59,7 +52,7 @@ const fetchTopLanguages = async (
59
52
 
60
53
  if (res.data.errors) {
61
54
  logger.error(res.data.errors);
62
- const firstError = res.data.errors[0];
55
+ const [firstError] = res.data.errors;
63
56
  if (firstError?.type === 'NOT_FOUND') {
64
57
  throw new CardError(firstError.message || 'Could not fetch user.', {
65
58
  code: 'not_found',
@@ -82,53 +75,46 @@ const fetchTopLanguages = async (
82
75
  const allExcludedRepos = [...exclude_repo, ...config.excludeRepositories];
83
76
 
84
77
  // populate repoToHide map for quick lookup while filtering out
85
- allExcludedRepos.forEach((repoName) => {
78
+ for (const repoName of allExcludedRepos) {
86
79
  repoToHide[repoName] = true;
87
- });
80
+ }
88
81
 
89
82
  // filter out repositories to be hidden
90
83
  const repoNodes = (res.data.data.user?.repositories.nodes ?? []).filter(
91
84
  (node): node is TopLanguagesRepositoryFragment => !!node && !repoToHide[node.name],
92
85
  );
93
86
 
94
- // flatten edges across repos. Order matters: `concat(acc)` prepends, and the
95
- // shared repoCount below depends on visitation order.
96
- const languageEdges = repoNodes.reduce<Array<TopLanguageFragment>>((acc, repo) => {
87
+ let languageEdges: Array<TopLanguageFragment> = [];
88
+ for (const repo of repoNodes) {
97
89
  const edges = (repo.languages?.edges ?? []).filter(
98
90
  (edge): edge is TopLanguageFragment => !!edge,
99
91
  );
100
- return edges.length > 0 ? edges.concat(acc) : acc;
101
- }, []);
92
+ if (edges.length > 0) {
93
+ languageEdges = [...edges, ...languageEdges];
94
+ }
95
+ }
102
96
 
103
- // accumulate size and repo count per language
97
+ // accumulate size and repo count per language, each read back off the language's own entry
104
98
  const languageMap: Record<string, Lang> = {};
105
- let repoCount = 0;
106
99
  for (const edge of languageEdges) {
107
100
  const existing = languageMap[edge.node.name];
108
-
109
- // same language seen again: add to its size and bump count; else reset to 1
110
- let langSize = edge.size;
111
- if (existing && edge.node.name === existing.name) {
112
- langSize = edge.size + existing.size;
113
- repoCount += 1;
114
- } else {
115
- repoCount = 1;
116
- }
117
101
  languageMap[edge.node.name] = {
118
102
  name: edge.node.name,
119
103
  color: edge.node.color,
120
- size: langSize,
121
- count: repoCount,
104
+ size: edge.size + (existing?.size ?? 0),
105
+ count: (existing?.count ?? 0) + 1,
122
106
  };
123
107
  }
124
108
 
125
109
  // comparison index calculation
126
110
  for (const lang of Object.values(languageMap)) {
127
- lang.size = Math.pow(lang.size, size_weight) * Math.pow(lang.count, count_weight);
111
+ lang.size = lang.size ** size_weight * lang.count ** count_weight;
128
112
  }
129
113
 
130
114
  // return languages sorted by (weighted) size, descending
131
- return Object.fromEntries(Object.entries(languageMap).sort(([, a], [, b]) => b.size - a.size));
115
+ return Object.fromEntries(
116
+ Object.entries(languageMap).toSorted(([, a], [, b]) => b.size - a.size),
117
+ );
132
118
  };
133
119
 
134
120
  export { fetchTopLanguages };
@@ -1,4 +1,4 @@
1
- import type { RepoInfoFragment } from '../graphql/generated/repo.js';
1
+ import type { RepoInfoFragment } from '../graphql/generated/repo.ts';
2
2
 
3
3
  export interface GistData {
4
4
  name: string;
@@ -1,18 +1,14 @@
1
- import type { CardConfig } from '../common/config.js';
2
- import { CardError, WAKATIME_USER_NOT_FOUND } from '../common/error.js';
3
- import { httpRequest } from '../common/http.js';
1
+ import type { CardConfig } from '../common/config.ts';
2
+ import { CardError, WAKATIME_USER_NOT_FOUND } from '../common/error.ts';
3
+ import { httpRequest } from '../common/http.ts';
4
4
 
5
- import type { WakaTimeData } from './types.js';
5
+ import type { WakaTimeData } from './types.ts';
6
6
 
7
7
  /**
8
8
  * WakaTime data fetcher.
9
9
  *
10
10
  * WakaTime needs no GitHub token; the config is here for the transport it carries.
11
11
  *
12
- * @param props Fetcher props.
13
- * @param props.username WakaTime username.
14
- * @param props.api_domain Optional WakaTime API domain (defaults to `wakatime.com`).
15
- * @param config Deployment config supplying the transport.
16
12
  * @returns WakaTime data response.
17
13
  */
18
14
  const fetchWakatimeStats = async (
@@ -29,7 +25,7 @@ const fetchWakatimeStats = async (
29
25
  throw CardError.missingParam(['username']);
30
26
  }
31
27
 
32
- const domain = api_domain ? api_domain.replace(/\/$/gi, '') : 'wakatime.com';
28
+ const domain = api_domain ? api_domain.replaceAll(/\/$/gi, '') : 'wakatime.com';
33
29
  const res = await httpRequest<{ data: WakaTimeData }>(
34
30
  config.fetch,
35
31
  `https://${domain}/api/v1/users/${username}/stats?is_including_today=true`,
@@ -1,7 +1,8 @@
1
- import { getGitHubYearRange, toGitHubDateTime } from '../common/date.js';
1
+ import { getGitHubYearRange, toGitHubDateTime } from '../common/date.ts';
2
2
 
3
- import type { YearContributionsFragment } from './generated/stats.js';
4
- import { graphqlDocument } from './graphqlDocument.js';
3
+ import type { YearContributionsFragment } from './generated/stats.ts';
4
+ import type { GraphQLDocument } from './graphqlDocument.ts';
5
+ import { graphqlDocument } from './graphqlDocument.ts';
5
6
 
6
7
  interface ContributionsQueryVariables {
7
8
  login: string;
@@ -15,10 +16,11 @@ interface ContributionsQuery {
15
16
  * Build the all-time contributions query, one aliased `contributionsCollection` field per year.
16
17
  * The shape is only known at runtime.
17
18
  *
18
- * @param years Contribution years, one `year_<year>` alias each.
19
19
  * @returns Document for `createGraphQLFetcher`.
20
20
  */
21
- const buildContributionsDocument = (years: Array<number>) => {
21
+ const buildContributionsDocument = (
22
+ years: Array<number>,
23
+ ): GraphQLDocument<ContributionsQuery, ContributionsQueryVariables> => {
22
24
  const yearFields = years
23
25
  .map((year) => {
24
26
  const { from, to } = getGitHubYearRange(year);
@@ -2,7 +2,7 @@
2
2
 
3
3
  /** Internal type. DO NOT USE DIRECTLY. */
4
4
  type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
5
- import { graphqlDocument } from '../graphqlDocument.js';
5
+ import { graphqlDocument } from '../graphqlDocument.ts';
6
6
  export type GistFileInfoFragment = {
7
7
  name: string | null;
8
8
  size: number | null;
@@ -2,7 +2,7 @@
2
2
 
3
3
  /** Internal type. DO NOT USE DIRECTLY. */
4
4
  type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
5
- import { graphqlDocument } from '../graphqlDocument.js';
5
+ import { graphqlDocument } from '../graphqlDocument.ts';
6
6
  export type RepoInfoFragment = {
7
7
  name: string;
8
8
  nameWithOwner: string;
@@ -2,9 +2,9 @@
2
2
 
3
3
  /** Internal type. DO NOT USE DIRECTLY. */
4
4
  type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
5
- import { graphqlDocument } from '../graphqlDocument.js';
5
+ import { graphqlDocument } from '../graphqlDocument.ts';
6
6
 
7
- import type * as Types from './common.js';
7
+ import type * as Types from './common.ts';
8
8
  export type RepoNodeFragment = { name: string; stargazerCount: number };
9
9
 
10
10
  export type RepoStarsFragment = {
@@ -2,9 +2,9 @@
2
2
 
3
3
  /** Internal type. DO NOT USE DIRECTLY. */
4
4
  type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
5
- import { graphqlDocument } from '../graphqlDocument.js';
5
+ import { graphqlDocument } from '../graphqlDocument.ts';
6
6
 
7
- import type * as Types from './common.js';
7
+ import type * as Types from './common.ts';
8
8
  export type TopLanguageFragment = { size: number; node: { color: string | null; name: string } };
9
9
 
10
10
  export type TopLanguagesRepositoryFragment = {
@@ -14,7 +14,6 @@ interface GraphQLDocument<TResult, TVariables> {
14
14
  /**
15
15
  * Build a typed document.
16
16
  *
17
- * @param text The query text, including every fragment it spreads.
18
17
  * @returns The document to pass to the function created by `createGraphQLFetcher`.
19
18
  */
20
19
  const graphqlDocument = <TResult, TVariables>(
@@ -1,8 +1,9 @@
1
- import type { GitHubDateRange } from '../common/date.js';
2
- import { toGitHubDateTime } from '../common/date.js';
1
+ import type { GitHubDateRange } from '../common/date.ts';
2
+ import { toGitHubDateTime } from '../common/date.ts';
3
3
 
4
- import type { RangeContributionsByRepoFragment } from './generated/stats.js';
5
- import { graphqlDocument } from './graphqlDocument.js';
4
+ import type { RangeContributionsByRepoFragment } from './generated/stats.ts';
5
+ import type { GraphQLDocument } from './graphqlDocument.ts';
6
+ import { graphqlDocument } from './graphqlDocument.ts';
6
7
 
7
8
  /** max value GitHub allows for `first/maxRepositories` */
8
9
  const MAX_REPOSITORIES_LIMIT = 100;
@@ -24,14 +25,12 @@ interface ReposContributedToQuery {
24
25
  * Mirrors the `contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]`
25
26
  * filter used by `repositoriesContributedTo` in `stats.graphql`.
26
27
  *
27
- * @param ranges Ranges to fetch, one `range_<index>` alias each.
28
- * @param includeOwnRepos Whether to select `repositoryContributions`.
29
28
  * @returns Document for `createGraphQLFetcher`.
30
29
  */
31
30
  const buildReposContributedToDocument = (
32
31
  ranges: Array<GitHubDateRange>,
33
32
  includeOwnRepos: boolean,
34
- ) => {
33
+ ): GraphQLDocument<ReposContributedToQuery, ReposContributedToQueryVariables> => {
35
34
  const rangeFields = ranges
36
35
  .map(
37
36
  ({ from, to }, index) =>
@@ -0,0 +1,118 @@
1
+ import type { Sink } from './serialize.ts';
2
+ import { INDENT, indentAt } from './serialize.ts';
3
+
4
+ /** Every CSS property a card declares, closed so a typo is a compile error, not a dropped rule. */
5
+ type CssProperty =
6
+ | '-webkit-box-orient'
7
+ | '-webkit-line-clamp'
8
+ | 'animation'
9
+ | 'animation-delay'
10
+ | 'animation-duration'
11
+ | 'color'
12
+ | 'display'
13
+ | 'fill'
14
+ | 'font'
15
+ | 'font-size'
16
+ | 'font-variant-numeric'
17
+ | 'font-weight'
18
+ | 'line-clamp'
19
+ | 'line-height'
20
+ | 'margin'
21
+ | 'opacity'
22
+ | 'overflow'
23
+ | 'overflow-wrap'
24
+ | 'padding-bottom'
25
+ | 'stroke'
26
+ | 'stroke-dasharray'
27
+ | 'stroke-dashoffset'
28
+ | 'stroke-linecap'
29
+ | 'stroke-width'
30
+ | 'text-overflow'
31
+ | 'transform'
32
+ | 'transform-origin'
33
+ | 'width'
34
+ | 'word-break';
35
+
36
+ /** A declaration whose value is `undefined` is left out, so a caller can inline a condition. */
37
+ type Declarations = Partial<Record<CssProperty, string | number | undefined>>;
38
+
39
+ type CssNode =
40
+ | { readonly kind: 'rule'; readonly selector: string; readonly declarations: Declarations }
41
+ | { readonly kind: 'at'; readonly prelude: string; readonly children: Array<CssChild> }
42
+ | { readonly kind: 'comment'; readonly text: string };
43
+
44
+ /** Anything accepted where CSS is expected; falsy entries drop out, arrays flatten. */
45
+ type CssChild = CssNode | false | null | undefined | Array<CssChild>;
46
+
47
+ /**
48
+ * Selectors stay free-form: they compose (`.badge rect`), and a keyframe stop is one too (`from`).
49
+ */
50
+ const rule = (selector: string, declarations: Declarations): CssNode => ({
51
+ kind: 'rule',
52
+ selector,
53
+ declarations,
54
+ });
55
+
56
+ const atRule = (prelude: string, ...children: Array<CssChild>): CssNode => ({
57
+ kind: 'at',
58
+ prelude,
59
+ children,
60
+ });
61
+
62
+ const cssComment = (text: string): CssNode => ({ kind: 'comment', text });
63
+
64
+ /** Drops the rules that carry nothing and flattens the arrays. */
65
+ const flatten = (children: Array<CssChild>, into: Array<CssNode> = []): Array<CssNode> => {
66
+ for (const child of children) {
67
+ if (!child) {
68
+ continue;
69
+ }
70
+ if (Array.isArray(child)) {
71
+ flatten(child, into);
72
+ } else {
73
+ into.push(child);
74
+ }
75
+ }
76
+ return into;
77
+ };
78
+
79
+ /**
80
+ * Serializes a stylesheet, dropping every rule that would have come out empty.
81
+ *
82
+ * A block is written optimistically and rewound if its body turns out empty,
83
+ * so the tree is walked once however deeply the at-rules nest.
84
+ */
85
+ const writeCss = (children: Array<CssChild>, depth: number, sink: Sink): void => {
86
+ const indent = indentAt(depth);
87
+
88
+ for (const node of flatten(children)) {
89
+ if (node.kind === 'comment') {
90
+ sink.out += `${indent}/* ${node.text} */\n`;
91
+ continue;
92
+ }
93
+
94
+ const mark = sink.out.length;
95
+
96
+ if (node.kind === 'at') {
97
+ sink.out += `${indent}${node.prelude} {\n`;
98
+ const bodyStart = sink.out.length;
99
+ writeCss(node.children, depth + 1, sink);
100
+ sink.out =
101
+ sink.out.length === bodyStart ? sink.out.slice(0, mark) : `${sink.out}${indent}}\n`;
102
+ continue;
103
+ }
104
+
105
+ sink.out += `${indent}${node.selector} {\n`;
106
+ const bodyStart = sink.out.length;
107
+ for (const property of Object.keys(node.declarations) as Array<CssProperty>) {
108
+ const value = node.declarations[property];
109
+ if (value !== undefined) {
110
+ sink.out += `${indent}${INDENT}${property}: ${String(value)};\n`;
111
+ }
112
+ }
113
+ sink.out = sink.out.length === bodyStart ? sink.out.slice(0, mark) : `${sink.out}${indent}}\n`;
114
+ }
115
+ };
116
+
117
+ export { atRule, cssComment, rule, writeCss };
118
+ export type { CssChild, Declarations };
@@ -0,0 +1,246 @@
1
+ import type { CssChild } from './css.ts';
2
+ import { writeCss } from './css.ts';
3
+ import { encodeHTML } from './html.ts';
4
+ import type { Sink } from './serialize.ts';
5
+ import { indentAt } from './serialize.ts';
6
+
7
+ /**
8
+ * Every element a card draws, closed so `el('circel', …)` is a compile error.
9
+ * `style` is absent on purpose: it comes from `style()`, which takes rules rather than children.
10
+ */
11
+ type Tag =
12
+ | 'a'
13
+ | 'circle'
14
+ | 'defs'
15
+ | 'desc'
16
+ | 'div'
17
+ | 'foreignObject'
18
+ | 'g'
19
+ | 'linearGradient'
20
+ | 'mask'
21
+ | 'path'
22
+ | 'rect'
23
+ | 'stop'
24
+ | 'svg'
25
+ | 'text'
26
+ | 'title'
27
+ | 'tspan';
28
+
29
+ /**
30
+ * Every attribute a card writes, closed like `Tag`:
31
+ * a misspelled `stroke-width` would otherwise be valid TypeScript and invisible in the output.
32
+ */
33
+ type Attribute =
34
+ | 'alignment-baseline'
35
+ | 'aria-hidden'
36
+ | 'aria-labelledby'
37
+ | 'class'
38
+ | 'cx'
39
+ | 'cy'
40
+ | 'd'
41
+ | 'data-testid'
42
+ | 'data-view-component'
43
+ | 'dominant-baseline'
44
+ | 'dy'
45
+ | 'fill'
46
+ | 'fill-rule'
47
+ | 'gradientTransform'
48
+ | 'gradientUnits'
49
+ | 'height'
50
+ | 'href'
51
+ | 'id'
52
+ | 'mask'
53
+ | 'offset'
54
+ | 'r'
55
+ | 'role'
56
+ | 'rx'
57
+ | 'ry'
58
+ | 'size'
59
+ | 'stop-color'
60
+ | 'stroke'
61
+ | 'stroke-dasharray'
62
+ | 'stroke-dashoffset'
63
+ | 'stroke-linecap'
64
+ | 'stroke-opacity'
65
+ | 'stroke-width'
66
+ | 'style'
67
+ | 'text-anchor'
68
+ | 'transform'
69
+ | 'version'
70
+ | 'viewBox'
71
+ | 'width'
72
+ | 'x'
73
+ | 'xmlns'
74
+ | 'y';
75
+
76
+ /** An attribute whose value is `undefined` is not written, so a caller can inline a condition. */
77
+ type Attributes = Partial<Record<Attribute, string | number | undefined>>;
78
+
79
+ /** Elements whose character data is part of the rendering, so they stay on one line. */
80
+ const INLINE_ELEMENTS: ReadonlySet<Tag> = new Set(['text', 'tspan', 'title', 'desc', 'div']);
81
+
82
+ interface MarkupElement {
83
+ readonly tag: Tag;
84
+ readonly attributes: Attributes;
85
+ readonly children: Array<Child>;
86
+ }
87
+
88
+ interface StyleElement {
89
+ readonly tag: 'style';
90
+ readonly rules: Array<CssChild>;
91
+ }
92
+
93
+ /** Anything accepted as content; falsy entries drop out, arrays flatten, strings are escaped. */
94
+ type Child =
95
+ | MarkupElement
96
+ | StyleElement
97
+ | string
98
+ | number
99
+ | false
100
+ | null
101
+ | undefined
102
+ | Array<Child>;
103
+
104
+ /** What is left of a child list once the empty entries and the nesting are gone. */
105
+ type FlatChild = MarkupElement | StyleElement | string | number;
106
+
107
+ /**
108
+ * Builds one element. Attribute values and text children are encoded on the way out.
109
+ */
110
+ const el = (tag: Tag, attributes: Attributes = {}, ...children: Array<Child>): MarkupElement => ({
111
+ tag,
112
+ attributes,
113
+ children,
114
+ });
115
+
116
+ /**
117
+ * Builds the `<style>` element from a stylesheet rather than from text.
118
+ */
119
+ const style = (...rules: Array<CssChild>): StyleElement => ({ tag: 'style', rules });
120
+
121
+ const isStyle = (child: MarkupElement | StyleElement): child is StyleElement => 'rules' in child;
122
+
123
+ /** Drops the children that carry nothing and flattens the arrays. */
124
+ const flatten = (children: Array<Child>, into: Array<FlatChild> = []): Array<FlatChild> => {
125
+ for (const child of children) {
126
+ if (child === false || child === null || child === undefined || child === '') {
127
+ continue;
128
+ }
129
+ if (Array.isArray(child)) {
130
+ flatten(child, into);
131
+ } else {
132
+ into.push(child);
133
+ }
134
+ }
135
+ return into;
136
+ };
137
+
138
+ const openTag = (node: MarkupElement, selfClosing: boolean): string => {
139
+ let tag = `<${node.tag}`;
140
+ const { attributes } = node;
141
+
142
+ // `Object.keys` widens to `string`, so the cast restores what the type already says.
143
+ for (const name of Object.keys(attributes) as Array<Attribute>) {
144
+ const value = attributes[name];
145
+ if (value !== undefined) {
146
+ // A number can hold nothing that needs escaping, so it skips the scan.
147
+ tag += typeof value === 'number' ? ` ${name}="${value}"` : ` ${name}="${encodeHTML(value)}"`;
148
+ }
149
+ }
150
+
151
+ return `${tag}${selfClosing ? ' />' : '>'}`;
152
+ };
153
+
154
+ /** Serializes an element and its descendants onto one line, whitespace and all. */
155
+ const writeInline = (node: FlatChild): string => {
156
+ if (typeof node === 'string') {
157
+ return encodeHTML(node);
158
+ }
159
+ if (typeof node === 'number') {
160
+ return String(node);
161
+ }
162
+ if (isStyle(node)) {
163
+ return '';
164
+ }
165
+
166
+ const children = flatten(node.children);
167
+ if (children.length === 0) {
168
+ return openTag(node, true);
169
+ }
170
+
171
+ let inner = '';
172
+ for (const child of children) {
173
+ inner += writeInline(child);
174
+ }
175
+ return `${openTag(node, false)}${inner}</${node.tag}>`;
176
+ };
177
+
178
+ /**
179
+ * A container is written optimistically and rewound if its content turns out empty,
180
+ * so an element that draws nothing costs one walk rather than two.
181
+ */
182
+ const write = (node: FlatChild, depth: number, sink: Sink): void => {
183
+ const indent = indentAt(depth);
184
+
185
+ if (typeof node === 'string') {
186
+ sink.out += `${indent}${encodeHTML(node)}\n`;
187
+ return;
188
+ }
189
+ if (typeof node === 'number') {
190
+ sink.out += `${indent}${node}\n`;
191
+ return;
192
+ }
193
+
194
+ const mark = sink.out.length;
195
+
196
+ if (isStyle(node)) {
197
+ sink.out += `${indent}<style>\n`;
198
+ const bodyStart = sink.out.length;
199
+ writeCss(node.rules, depth + 1, sink);
200
+ sink.out =
201
+ sink.out.length === bodyStart ? sink.out.slice(0, mark) : `${sink.out}${indent}</style>\n`;
202
+ return;
203
+ }
204
+
205
+ const children = flatten(node.children);
206
+ if (children.length === 0) {
207
+ sink.out += `${indent}${openTag(node, true)}\n`;
208
+ return;
209
+ }
210
+
211
+ if (INLINE_ELEMENTS.has(node.tag)) {
212
+ let inner = '';
213
+ for (const child of children) {
214
+ inner += writeInline(child);
215
+ }
216
+ sink.out +=
217
+ inner === ''
218
+ ? `${indent}${openTag(node, true)}\n`
219
+ : `${indent}${openTag(node, false)}${inner}</${node.tag}>\n`;
220
+ return;
221
+ }
222
+
223
+ sink.out += `${indent}${openTag(node, false)}\n`;
224
+ const bodyStart = sink.out.length;
225
+ for (const child of children) {
226
+ write(child, depth + 1, sink);
227
+ }
228
+ sink.out =
229
+ sink.out.length === bodyStart
230
+ ? `${sink.out.slice(0, mark)}${indent}${openTag(node, true)}\n`
231
+ : `${sink.out}${indent}</${node.tag}>\n`;
232
+ };
233
+
234
+ /**
235
+ * Serializes a finished card: one element per line, two spaces per nesting level.
236
+ *
237
+ * @returns The rendered SVG.
238
+ */
239
+ const renderMarkup = (root: MarkupElement): string => {
240
+ const sink: Sink = { out: '' };
241
+ write(root, 0, sink);
242
+ return sink.out;
243
+ };
244
+
245
+ export { el, renderMarkup, style };
246
+ export type { Child, MarkupElement };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * What has to be encoded: the markup characters, everything above plain ASCII, and a backspace.
3
+ * One source for both regexes, so the check and the replacement cannot disagree.
4
+ */
5
+ const ENCODED = String.raw`[<>&"'\u00A0-\u9999\u0008]`;
6
+
7
+ /** Non-global, so `.test` stays stateless: a global regex would carry `lastIndex`. */
8
+ const NEEDS_ENCODING = new RegExp(ENCODED);
9
+ const TO_ENCODE = new RegExp(ENCODED, 'g');
10
+
11
+ const BACKSPACE = '\u0008';
12
+
13
+ /**
14
+ * Encodes a string for use as markup, one numeric character reference each.
15
+ *
16
+ * The serializer is now the only place text becomes markup, so nothing arrives pre-escaped:
17
+ * a literal `&#38;` is encoded like any other `&` rather than passed through as a reference.
18
+ *
19
+ * @see https://stackoverflow.com/a/48073476/10629172
20
+ *
21
+ * @returns The text, safe to place in an attribute or as character data.
22
+ */
23
+ const encodeHTML = (str: string): string => {
24
+ // Most strings a card writes are plain ASCII, so they leave without a rewrite.
25
+ if (!NEEDS_ENCODING.test(str)) {
26
+ return str;
27
+ }
28
+
29
+ return str.replace(TO_ENCODE, (char) =>
30
+ char === BACKSPACE ? '' : `&#${char.codePointAt(0) ?? 0};`,
31
+ );
32
+ };
33
+
34
+ export { encodeHTML };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The markup layer: a card is built as a tree of elements and rules, then serialized once.
3
+ * It depends on nothing else in the package, so it sits below `cards/`, not inside `common/`.
4
+ */
5
+
6
+ export { atRule, cssComment, rule } from './css.ts';
7
+ export type { CssChild, Declarations } from './css.ts';
8
+ export { el, renderMarkup, style } from './element.ts';
9
+ export type { Child, MarkupElement } from './element.ts';