so://phie

figure

The vanilla alternative for writing JSX-based React applications
git clone git://soophie.de/figure

blob: 208c3e449ff96af34daadf3502d9aaec2e737bb9 (8341 bytes)[doc][blame][raw]

1// @ts-check

2

3/**

4 * @typedef {Object} ReactElement

5 * @typedef {function(Props=): ReactElement} ReactFunction

6 * @typedef {function(ReactFunction | string, Props?=, ...Object): ReactElement} ReactCreateFunction

7 *

8 * @typedef {TemplateStringsArray} Slices

9 * @typedef {any} Value

10 * @typedef {{[key: string]: any}} Refs

11 * @typedef {any} Props

12 * @typedef {{[key: string]: ReactFunction | Dict}} Dict

13 * @typedef {{ dict: DictFunction, dyn: DynFunction }} Figure

14 *

15 * @typedef {function(Dict=): HTMLFunction} DictFunction

16 * @typedef {function(Slices, ...Value): ReactElement[]} HTMLFunction

17 * @typedef {ReactCreateFunction} DynFunction

18 */

19

20const EVENTS = [

21 'onCopy',

22 'onCut',

23 'onPaste',

24 'onCompositionEnd',

25 'onCompositionStart',

26 'onCompositionUpdate',

27 'onKeyDown',

28 'onKeyPress',

29 'onKeyUp',

30 'onFocus',

31 'onBlur',

32 'onChange',

33 'onInput',

34 'onInvalid',

35 'onReset',

36 'onSubmit',

37 'onError',

38 'onLoad',

39 'onClick',

40 'onContextMenu',

41 'onDoubleClick',

42 'onDrag',

43 'onDragEnd',

44 'onDragEnter',

45 'onDragExit',

46 'onDragLeave',

47 'onDragOver',

48 'onDragStart',

49 'onDrop',

50 'onMouseDown',

51 'onMouseEnter',

52 'onMouseLeave',

53 'onMouseMove',

54 'onMouseOut',

55 'onMouseOver',

56 'onMouseUp',

57 'onPointerDown',

58 'onPointerMove',

59 'onPointerUp',

60 'onPointerCancel',

61 'onGotPointerCapture',

62 'onLostPointerCapture',

63 'onPointerEnter',

64 'onPointerLeave',

65 'onPointerOver',

66 'onPointerOut',

67 'onSelect',

68 'onTouchCancel',

69 'onTouchEnd',

70 'onTouchMove',

71 'onTouchStart',

72 'onScroll',

73 'onWheel',

74 'onAbort',

75 'onCanPlay',

76 'onCanPlayThrough',

77 'onDurationChange',

78 'onEmptied',

79 'onEncrypted',

80 'onEnded',

81 'onLoadedData',

82 'onLoadedMetadata',

83 'onLoadStart',

84 'onPause',

85 'onPlay',

86 'onPlaying',

87 'onProgress',

88 'onRateChange',

89 'onSeeked',

90 'onSeeking',

91 'onStalled',

92 'onSuspend',

93 'onTimeUpdate',

94 'onVolumeChange',

95 'onWaiting',

96 'onAnimationStart',

97 'onAnimationEnd',

98 'onAnimationIteration',

99 'onTransitionEnd',

100 'onToggle',

101];

102

103/**

104 * Initializes the figure framework.

105 *

106 * @param {ReactCreateFunction} create The React createElement function

107 * @returns {Figure} The util functions collected in an object

108 */

109export default function figure(create) {

110

111 // the parser for interpreting HTML

112 const parser = new DOMParser();

113 // the counter for creating unique references

114 let count = 0;

115

116 /**

117 * Returns the a function for rendering HTML.

118 *

119 * @param {Dict} [dict] The dictionary for resolving React components

120 * @returns {HTMLFunction} The function for rendering HTML

121 */

122 function dict(dict) {

123

124 /**

125 * Converts the template literal HTML syntax into React elements.

126 *

127 * @param {Slices} slices The template literal slices

128 * @param {...Value} values The template literal values

129 * @returns {ReactElement[]} The converted HTML as React elements

130 */

131 function html(slices, ...values) {

132 const [html, refs] = compose(slices, values);

133 try {

134 const dom = parser.parseFromString(html, 'text/html');

135 // collect all nodes from head and body

136 const nodes = [...dom.head.childNodes, ...dom.body.childNodes];

137 return nodes.map((node) => render(node, refs, dict ?? {}));

138 }

139 catch (error) {

140 console.error(error);

141 throw 'Invalid DOM structure!';

142 }

143 }

144

145 return html;

146 }

147

148 /**

149 * Joins the template literal slices together and replaces the values with references.

150 * The values are being mapped to there corresponding references and with the populated

151 * HTML string returned.

152 *

153 * @param {Slices} slices The template literal slices

154 * @param {Value[]} values The template literal values

155 * @returns {[string, Refs]} The joined HTML string and the values mapped to there references

156 */

157 function compose(slices, values) {

158 if (slices == null) {

159 // handle dyn function without body

160 return ['', {}];

161 }

162 const refs = /** @type {Refs} */ ({});

163 let slice = '';

164 for (let i = 0; i < slices.length; i++) {

165 slice += slices[i];

166 if (values[i] != null) {

167 // create unique reference

168 const uid = `$fig-${count++}$`;

169 refs[uid] = values[i];

170 slice += uid ?? '';

171 }

172 }

173 return [slice.trim(), refs];

174 }

175

176 /**

177 * Injects the values into the corresponding reference locations of the string.

178 *

179 * @param {string} slice The string containing references

180 * @param {Refs} refs The values mapped to there references

181 * @returns {ReactElement[]} The string populated with the passed values

182 */

183 function feed(slice, refs) {

184 const expr = /\$fig-\d+\$/g;

185 const elements = /** @type {ReactElement[]} */ ([]);

186 let match = null;

187 let last = 0;

188 while ((match = expr.exec(slice)) !== null) {

189 const index = match.index;

190 const uid = match[0];

191 const before = slice.substring(last, index);

192 // ignore empty strings

193 if (before.length > 0) {

194 elements.push(before);

195 }

196 const value = refs[uid];

197 // ignore empty values

198 if (value !== undefined && value !== null) {

199 elements.push(value);

200 }

201 last = index + uid.length;

202 }

203 const after = slice.substring(last);

204 // ignore empty strings

205 if (after.length > 0) {

206 elements.push(after);

207 }

208 return elements;

209 }

210

211 /**

212 * Converts a HTML node into a React element.

213 *

214 * @param {Node} node The HTML node

215 * @param {Refs} refs The values mapped to there references

216 * @param {Dict} dict The dictionary for resolving React components

217 * @returns {ReactElement[]} The converted HTML node as React element

218 */

219 function render(node, refs, dict) {

220 if (node.nodeType === Node.TEXT_NODE) {

221 const text = /** @type {Text} */ (node);

222 if (text.textContent == null) {

223 // ignore empty text nodes

224 return [];

225 }

226 return feed(text.textContent, refs);

227 }

228 if (node.nodeType === Node.COMMENT_NODE) {

229 // ignore comments

230 return [];

231 }

232 const element = /** @type {HTMLElement} */ (node);

233 const tag = element.tagName.toLowerCase();

234 const props = /** @type {Props} */ ({});

235 // iterate over each attribute and add it to the props

236 for (const attribute of element.attributes) {

237 const key = attribute.name;

238 const slice = attribute.textContent;

239 if (slice == null) {

240 // ignore empty attribute values

241 continue;

242 }

243 const values = feed(slice, refs);

244 const value = values.length == 1 ? values[0] : values;

245 let attr = key;

246 const eventMatch = /^(\w+):(\w+)$/.exec(attr);

247 if (eventMatch) {

248 // camel case attribute name

249 const [, pre, name] = eventMatch;

250 for (const event of EVENTS) {

251 // find matching event name

252 if (`${pre}${name}` === event.toLowerCase()) {

253 attr = event;

254 break;

255 }

256 }

257 }

258 const propsMatch = /^\$props$/.exec(attr);

259 if (propsMatch) {

260 // bind all props which are passed to $props

261 for (const [key, prop] of Object.entries(value)) {

262 props[key] = prop;

263 }

264 }

265 const htmlMatch = /^\$html$/.exec(attr);

266 if (htmlMatch) {

267 // bind inner html

268 props['dangerouslySetInnerHTML'] = { __html: value };

269 }

270 props[attr] = value instanceof Array ? value.join('') : value;

271 }

272 const children = /** @type {ReactElement[]} */ ([]);

273 // recursively render all child nodes

274 (node.childNodes ?? []).forEach((child) => children.push(...render(child, refs, dict)));

275 const domains = tag.split(':');

276 let component = /** @type {ReactFunction | null} */ (null);

277 let layer = /** @type {Dict | ReactFunction | null} */ (dict);

278 // look up tag name in dictionary

279 for (const domain of domains) {

280 if (layer && typeof layer === 'object' && layer[domain]) {

281 // traverse sublayer

282 layer = /** @type {Dict} */ (layer[domain]);

283 continue;

284 }

285 // domain is not in layer

286 layer = null;

287 break;

288 }

289 if (layer) {

290 // found component for tag

291 component = /** @type {ReactFunction} */ (layer);

292 }

293 // use React component or tag name

294 return [create(component ?? tag, props, ...children)];

295 }

296

297 return { dict, dyn: create };

298}