html.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928
  1. import t from 'tap';
  2. import * as html from '#html';
  3. import {strictlyThrows} from '#test-lib';
  4. const {Tag, Attributes, Template} = html;
  5. t.test(`html.tag`, t => {
  6. t.plan(14);
  7. const tag1 =
  8. html.tag('div',
  9. {[html.onlyIfContent]: true, foo: 'bar'},
  10. 'child');
  11. // 1-5: basic behavior when passing attributes
  12. t.ok(tag1 instanceof Tag);
  13. t.ok(tag1.onlyIfContent);
  14. t.equal(tag1.attributes.get('foo'), 'bar');
  15. t.equal(tag1.content.length, 1);
  16. t.equal(tag1.content[0], 'child');
  17. const tag2 = html.tag('div', ['two', 'children']);
  18. // 6-8: basic behavior when not passing attributes
  19. t.equal(tag2.content.length, 2);
  20. t.equal(tag2.content[0], 'two');
  21. t.equal(tag2.content[1], 'children');
  22. const genericTag = html.tag('div');
  23. const genericTemplate = html.template({
  24. content: () => html.blank(),
  25. });
  26. // 9-10: tag treated as content, not attributes
  27. const tag3 = html.tag('div', genericTag);
  28. t.equal(tag3.content.length, 1);
  29. t.equal(tag3.content[0], genericTag);
  30. // 11-12: template treated as content, not attributes
  31. const tag4 = html.tag('div', genericTemplate);
  32. t.equal(tag4.content.length, 1);
  33. t.equal(tag4.content[0], genericTemplate);
  34. // 13-14: deep flattening support
  35. const tag6 =
  36. html.tag('div', [
  37. true &&
  38. [[[[[[
  39. true &&
  40. [[[[[`That's deep.`]]]]],
  41. ]]]]]],
  42. ]);
  43. t.equal(tag6.content.length, 1);
  44. t.equal(tag6.content[0], `That's deep.`);
  45. });
  46. t.test(`Tag (basic interface)`, t => {
  47. t.plan(11);
  48. const tag1 = new Tag();
  49. // 1-5: essential properties & no arguments provided
  50. t.equal(tag1.tagName, '');
  51. t.ok(Array.isArray(tag1.content));
  52. t.equal(tag1.content.length, 0);
  53. t.ok(tag1.attributes instanceof Attributes);
  54. t.equal(tag1.attributes.toString(), '');
  55. const tag2 = new Tag('div', {id: 'banana'}, ['one', 'two', tag1]);
  56. // 6-11: properties on basic usage
  57. t.equal(tag2.tagName, 'div');
  58. t.equal(tag2.content.length, 3);
  59. t.equal(tag2.content[0], 'one');
  60. t.equal(tag2.content[1], 'two');
  61. t.equal(tag2.content[2], tag1);
  62. t.equal(tag2.attributes.get('id'), 'banana');
  63. });
  64. t.test(`Tag (self-closing)`, t => {
  65. t.plan(10);
  66. const tag1 = new Tag('br');
  67. const tag2 = new Tag('div');
  68. const tag3 = new Tag('div');
  69. tag3.tagName = 'br';
  70. // 1-3: selfClosing depends on tagName
  71. t.ok(tag1.selfClosing);
  72. t.notOk(tag2.selfClosing);
  73. t.ok(tag3.selfClosing);
  74. // 4: constructing self-closing tag with content throws
  75. t.throws(() => new Tag('br', null, 'bananas'), /self-closing/);
  76. // 5: setting content on self-closing tag throws
  77. t.throws(() => { tag1.content = ['suspicious']; }, /self-closing/);
  78. // 6-9: setting empty content on self-closing tag doesn't throw
  79. t.doesNotThrow(() => { tag1.content = null; });
  80. t.doesNotThrow(() => { tag1.content = undefined; });
  81. t.doesNotThrow(() => { tag1.content = ''; });
  82. t.doesNotThrow(() => { tag1.content = [null, '', false]; });
  83. const tag4 = new Tag('div', null, 'bananas');
  84. // 10: changing tagName to self-closing when tag has content throws
  85. t.throws(() => { tag4.tagName = 'br'; }, /self-closing/);
  86. });
  87. t.test(`Tag (properties from attributes - from constructor)`, t => {
  88. t.plan(6);
  89. const tag = new Tag('div', {
  90. [html.onlyIfContent]: true,
  91. [html.noEdgeWhitespace]: true,
  92. [html.joinChildren]: '<br>',
  93. });
  94. // 1-3: basic exposed properties from attributes in constructor
  95. t.ok(tag.onlyIfContent);
  96. t.ok(tag.noEdgeWhitespace);
  97. t.equal(tag.joinChildren, '<br>');
  98. // 4-6: property values stored on attributes with public symbols
  99. t.equal(tag.attributes.get(html.onlyIfContent), true);
  100. t.equal(tag.attributes.get(html.noEdgeWhitespace), true);
  101. t.equal(tag.attributes.get(html.joinChildren), '<br>');
  102. });
  103. t.test(`Tag (properties from attributes - mutating)`, t => {
  104. t.plan(12);
  105. // 1-3: exposed properties reflect reasonable attribute values
  106. const tag1 = new Tag('div', {
  107. [html.onlyIfContent]: true,
  108. [html.noEdgeWhitespace]: true,
  109. [html.joinChildren]: '<br>',
  110. });
  111. tag1.attributes.set(html.onlyIfContent, false);
  112. tag1.attributes.remove(html.noEdgeWhitespace);
  113. tag1.attributes.set(html.joinChildren, '🍇');
  114. t.equal(tag1.onlyIfContent, false);
  115. t.equal(tag1.noEdgeWhitespace, false);
  116. t.equal(tag1.joinChildren, '🍇');
  117. // 4-6: exposed properties reflect unreasonable attribute values
  118. const tag2 = new Tag('div', {
  119. [html.onlyIfContent]: true,
  120. [html.noEdgeWhitespace]: true,
  121. [html.joinChildren]: '<br>',
  122. });
  123. tag2.attributes.set(html.onlyIfContent, '');
  124. tag2.attributes.set(html.noEdgeWhitespace, 12345);
  125. tag2.attributes.set(html.joinChildren, 0.0001);
  126. t.equal(tag2.onlyIfContent, false);
  127. t.equal(tag2.noEdgeWhitespace, true);
  128. t.equal(tag2.joinChildren, '0.0001');
  129. // 7-9: attribute values reflect reasonable mutated properties
  130. const tag3 = new Tag('div', null, {
  131. [html.onlyIfContent]: false,
  132. [html.noEdgeWhitespace]: true,
  133. [html.joinChildren]: '🍜',
  134. })
  135. tag3.onlyIfContent = true;
  136. tag3.noEdgeWhitespace = false;
  137. tag3.joinChildren = '🦑';
  138. t.equal(tag3.attributes.get(html.onlyIfContent), true);
  139. t.equal(tag3.attributes.get(html.noEdgeWhitespace), undefined);
  140. t.equal(tag3.joinChildren, '🦑');
  141. // 10-12: attribute values reflect unreasonable mutated properties
  142. const tag4 = new Tag('div', null, {
  143. [html.onlyIfContent]: false,
  144. [html.noEdgeWhitespace]: true,
  145. [html.joinChildren]: '🍜',
  146. });
  147. tag4.onlyIfContent = 'armadillo';
  148. tag4.noEdgeWhitespace = 0;
  149. tag4.joinChildren = Infinity;
  150. t.equal(tag4.attributes.get(html.onlyIfContent), true);
  151. t.equal(tag4.attributes.get(html.noEdgeWhitespace), undefined);
  152. t.equal(tag4.attributes.get(html.joinChildren), 'Infinity');
  153. });
  154. t.test(`Tag.toString`, t => {
  155. t.plan(9);
  156. // 1: basic behavior
  157. const tag1 =
  158. html.tag('div', 'Content');
  159. t.equal(tag1.toString(),
  160. `<div>Content</div>`);
  161. // 2: stringifies nested element
  162. const tag2 =
  163. html.tag('div', html.tag('p', 'Content'));
  164. t.equal(tag2.toString(),
  165. `<div><p>Content</p></div>`);
  166. // 3: stringifies attributes
  167. const tag3 =
  168. html.tag('div',
  169. {
  170. id: 'banana',
  171. class: ['foo', 'bar'],
  172. contenteditable: true,
  173. biggerthanabreadbox: false,
  174. saying: `"To light a candle is to cast a shadow..."`,
  175. tabindex: 413,
  176. },
  177. 'Content');
  178. t.equal(tag3.toString(),
  179. `<div id="banana" class="foo bar" contenteditable ` +
  180. `saying="&quot;To light a candle is to cast a shadow...&quot;" ` +
  181. `tabindex="413">Content</div>`);
  182. // 4: attributes match input order
  183. const tag4 =
  184. html.tag('div',
  185. {class: ['foo', 'bar'], id: 'banana'},
  186. 'Content');
  187. t.equal(tag4.toString(),
  188. `<div class="foo bar" id="banana">Content</div>`);
  189. // 5: multiline contented indented
  190. const tag5 =
  191. html.tag('div', 'foo\nbar');
  192. t.equal(tag5.toString(),
  193. `<div>\n` +
  194. ` foo\n` +
  195. ` bar\n` +
  196. `</div>`);
  197. // 6: nested multiline content double-indented
  198. const tag6 =
  199. html.tag('div', [
  200. html.tag('p',
  201. 'foo\nbar'),
  202. html.tag('span', `I'm on one line!`),
  203. ]);
  204. t.equal(tag6.toString(),
  205. `<div>\n` +
  206. ` <p>\n` +
  207. ` foo\n` +
  208. ` bar\n` +
  209. ` </p>\n` +
  210. ` <span>I'm on one line!</span>\n` +
  211. `</div>`);
  212. // 7: self-closing (with attributes)
  213. const tag7 =
  214. html.tag('article', [
  215. html.tag('h1', `Title`),
  216. html.tag('hr', {style: `color: magenta`}),
  217. html.tag('p', `Shenanigans!`),
  218. ]);
  219. t.equal(tag7.toString(),
  220. `<article>\n` +
  221. ` <h1>Title</h1>\n` +
  222. ` <hr style="color: magenta">\n` +
  223. ` <p>Shenanigans!</p>\n` +
  224. `</article>`);
  225. // 8-9: empty tagName passes content through directly
  226. const tag8 =
  227. html.tag(null, [
  228. html.tag('h1', `Foo`),
  229. html.tag(`h2`, `Bar`),
  230. ]);
  231. t.equal(tag8.toString(),
  232. `<h1>Foo</h1>\n` +
  233. `<h2>Bar</h2>`);
  234. const tag9 =
  235. html.tag(null, {
  236. [html.joinChildren]: html.tag('br'),
  237. }, [
  238. `Say it with me...`,
  239. `Supercalifragilisticexpialidocious!`
  240. ]);
  241. t.equal(tag9.toString(),
  242. `Say it with me...\n` +
  243. `<br>\n` +
  244. `Supercalifragilisticexpialidocious!`);
  245. });
  246. t.test(`Tag.toString (onlyIfContent)`, t => {
  247. t.plan(4);
  248. // 1-2: basic behavior
  249. const tag1 =
  250. html.tag('div',
  251. {[html.onlyIfContent]: true},
  252. `Hello!`);
  253. t.equal(tag1.toString(),
  254. `<div>Hello!</div>`);
  255. const tag2 =
  256. html.tag('div',
  257. {[html.onlyIfContent]: true},
  258. '');
  259. t.equal(tag2.toString(),
  260. '');
  261. // 3-4: nested onlyIfContent with "more" content
  262. const tag3 =
  263. html.tag('div',
  264. {[html.onlyIfContent]: true},
  265. [
  266. '',
  267. 0,
  268. html.tag('h1',
  269. {[html.onlyIfContent]: true},
  270. html.tag('strong',
  271. {[html.onlyIfContent]: true})),
  272. null,
  273. false,
  274. ]);
  275. t.equal(tag3.toString(),
  276. '');
  277. const tag4 =
  278. html.tag('div',
  279. {[html.onlyIfContent]: true},
  280. [
  281. '',
  282. 0,
  283. html.tag('h1',
  284. {[html.onlyIfContent]: true},
  285. html.tag('strong')),
  286. null,
  287. false,
  288. ]);
  289. t.equal(tag4.toString(),
  290. `<div><h1><strong></strong></h1></div>`);
  291. });
  292. t.test(`Tag.toString (joinChildren, noEdgeWhitespace)`, t => {
  293. t.plan(6);
  294. // 1: joinChildren: default (\n), noEdgeWhitespace: true
  295. const tag1 =
  296. html.tag('div',
  297. {[html.noEdgeWhitespace]: true},
  298. [
  299. 'Foo',
  300. 'Bar',
  301. 'Baz',
  302. ]);
  303. t.equal(tag1.toString(),
  304. `<div>Foo\n` +
  305. ` Bar\n` +
  306. ` Baz</div>`);
  307. // 2: joinChildren: one-line string, noEdgeWhitespace: default (false)
  308. const tag2 =
  309. html.tag('div',
  310. {
  311. [html.joinChildren]:
  312. html.tag('br', {location: '🍍'}),
  313. },
  314. [
  315. 'Foo',
  316. 'Bar',
  317. 'Baz',
  318. ]);
  319. t.equal(tag2.toString(),
  320. `<div>\n` +
  321. ` Foo\n` +
  322. ` <br location="🍍">\n` +
  323. ` Bar\n` +
  324. ` <br location="🍍">\n` +
  325. ` Baz\n` +
  326. `</div>`);
  327. // 3-4: joinChildren: blank string, noEdgeWhitespace: default (false)
  328. const tag3 =
  329. html.tag('div',
  330. {[html.joinChildren]: ''},
  331. [
  332. 'Foo',
  333. 'Bar',
  334. 'Baz',
  335. ]);
  336. t.equal(tag3.toString(),
  337. `<div>FooBarBaz</div>`);
  338. const tag4 =
  339. html.tag('div',
  340. {[html.joinChildren]: ''},
  341. [
  342. `Ain't I\na cute one?`,
  343. `~`
  344. ]);
  345. t.equal(tag4.toString(),
  346. `<div>\n` +
  347. ` Ain't I\n` +
  348. ` a cute one?~\n` +
  349. `</div>`);
  350. // 5: joinChildren: one-line string, noEdgeWhitespace: true
  351. const tag5 =
  352. html.tag('div',
  353. {
  354. [html.joinChildren]: html.tag('br'),
  355. [html.noEdgeWhitespace]: true,
  356. },
  357. [
  358. 'Foo',
  359. 'Bar',
  360. 'Baz',
  361. ]);
  362. t.equal(tag5.toString(),
  363. `<div>Foo\n` +
  364. ` <br>\n` +
  365. ` Bar\n` +
  366. ` <br>\n` +
  367. ` Baz</div>`);
  368. // 6: joinChildren: empty string, noEdgeWhitespace: true
  369. const tag6 =
  370. html.tag('span',
  371. {
  372. [html.joinChildren]: '',
  373. [html.noEdgeWhitespace]: true,
  374. },
  375. [
  376. html.tag('i', `Oh yes~ `),
  377. `You're a cute one`,
  378. html.tag('sup', `💕`),
  379. ]);
  380. t.equal(tag6.toString(),
  381. `<span><i>Oh yes~ </i>You're a cute one<sup>💕</sup></span>`);
  382. });
  383. t.test(`html.template`, t => {
  384. t.plan(11);
  385. let contentCalls;
  386. // 1-4: basic behavior - no slots
  387. contentCalls = 0;
  388. const template1 = html.template({
  389. content() {
  390. contentCalls++;
  391. return html.tag('hr');
  392. },
  393. });
  394. t.equal(contentCalls, 0);
  395. t.equal(template1.toString(), `<hr>`);
  396. t.equal(contentCalls, 1);
  397. template1.toString();
  398. t.equal(contentCalls, 2);
  399. // 5-10: basic behavior - slots
  400. contentCalls = 0;
  401. const template2 = html.template({
  402. slots: {
  403. foo: {
  404. type: 'string',
  405. default: 'Default Message',
  406. },
  407. },
  408. content(slots) {
  409. contentCalls++;
  410. return html.tag('sub', slots.foo.toLowerCase());
  411. },
  412. });
  413. t.equal(contentCalls, 0);
  414. t.equal(template2.toString(), `<sub>default message</sub>`);
  415. t.equal(contentCalls, 1);
  416. template2.setSlot('foo', `R-r-really, me?`);
  417. t.equal(contentCalls, 1);
  418. t.equal(template2.toString(), `<sub>r-r-really, me?</sub>`);
  419. t.equal(contentCalls, 2);
  420. // 11: slot uses default only for null, not falsey
  421. const template3 = html.template({
  422. slots: {
  423. slot1: {type: 'number', default: 123},
  424. slot2: {type: 'number', default: 456},
  425. slot3: {type: 'boolean', default: true},
  426. slot4: {type: 'string', default: 'banana'},
  427. },
  428. content(slots) {
  429. return html.tag('span', [
  430. slots.slot1,
  431. slots.slot2,
  432. slots.slot3,
  433. `(length: ${slots.slot4.length})`,
  434. ].join(' '));
  435. },
  436. });
  437. template3.setSlots({
  438. slot1: null,
  439. slot2: 0,
  440. slot3: false,
  441. slot4: '',
  442. });
  443. t.equal(template3.toString(), `<span>123 0 false (length: 0)</span>`);
  444. });
  445. t.test(`Template - description errors`, t => {
  446. t.plan(14);
  447. // 1-3: top-level description is object
  448. strictlyThrows(t,
  449. () => Template.validateDescription('snooping as usual'),
  450. new TypeError(`Expected object, got string`));
  451. strictlyThrows(t,
  452. () => Template.validateDescription(),
  453. new TypeError(`Expected object, got undefined`));
  454. strictlyThrows(t,
  455. () => Template.validateDescription(null),
  456. new TypeError(`Expected object, got null`));
  457. // 4-5: description.content is function
  458. strictlyThrows(t,
  459. () => Template.validateDescription({}),
  460. new AggregateError([
  461. new TypeError(`Expected description.content`),
  462. ], `Errors validating template description`));
  463. strictlyThrows(t,
  464. () => Template.validateDescription({
  465. content: 'pingas',
  466. }),
  467. new AggregateError([
  468. new TypeError(`Expected description.content to be function`),
  469. ], `Errors validating template description`));
  470. // 6: aggregate error includes template annotation
  471. strictlyThrows(t,
  472. () => Template.validateDescription({
  473. annotation: `my cool template`,
  474. content: 'pingas',
  475. }),
  476. new AggregateError([
  477. new TypeError(`Expected description.content to be function`),
  478. ], `Errors validating template "my cool template" description`));
  479. // 7: description.slots is object
  480. strictlyThrows(t,
  481. () => Template.validateDescription({
  482. slots: 'pingas',
  483. content: () => {},
  484. }),
  485. new AggregateError([
  486. new TypeError(`Expected description.slots to be object`),
  487. ], `Errors validating template description`));
  488. // 8: slot description is object
  489. strictlyThrows(t,
  490. () => Template.validateDescription({
  491. slots: {
  492. mySlot: 'pingas',
  493. },
  494. content: () => {},
  495. }),
  496. new AggregateError([
  497. new AggregateError([
  498. new TypeError(`(mySlot) Expected slot description to be object`),
  499. ], `Errors in slot descriptions`),
  500. ], `Errors validating template description`))
  501. // 9-10: slot description has validate or default, not both
  502. strictlyThrows(t,
  503. () => Template.validateDescription({
  504. slots: {
  505. mySlot: {},
  506. },
  507. content: () => {},
  508. }),
  509. new AggregateError([
  510. new AggregateError([
  511. new TypeError(`(mySlot) Expected either slot validate or type`),
  512. ], `Errors in slot descriptions`),
  513. ], `Errors validating template description`));
  514. strictlyThrows(t,
  515. () => Template.validateDescription({
  516. slots: {
  517. mySlot: {
  518. validate: 'pingas',
  519. type: 'pingas',
  520. },
  521. },
  522. content: () => {},
  523. }),
  524. new AggregateError([
  525. new AggregateError([
  526. new TypeError(`(mySlot) Don't specify both slot validate and type`),
  527. ], `Errors in slot descriptions`),
  528. ], `Errors validating template description`));
  529. // 11: slot validate is function
  530. strictlyThrows(t,
  531. () => Template.validateDescription({
  532. slots: {
  533. mySlot: {
  534. validate: 'pingas',
  535. },
  536. },
  537. content: () => {},
  538. }),
  539. new AggregateError([
  540. new AggregateError([
  541. new TypeError(`(mySlot) Expected slot validate to be function`),
  542. ], `Errors in slot descriptions`),
  543. ], `Errors validating template description`));
  544. // 12: slot type is name of built-in type
  545. strictlyThrows(t,
  546. () => Template.validateDescription({
  547. slots: {
  548. mySlot: {
  549. type: 'pingas',
  550. },
  551. },
  552. content: () => {},
  553. }),
  554. new AggregateError([
  555. new AggregateError([
  556. /\(mySlot\) Expected slot type to be one of/,
  557. ], `Errors in slot descriptions`),
  558. ], `Errors validating template description`));
  559. // 13: slot type has specific errors for function & object
  560. strictlyThrows(t,
  561. () => Template.validateDescription({
  562. slots: {
  563. slot1: {type: 'function'},
  564. slot2: {type: 'object'},
  565. },
  566. content: () => {},
  567. }),
  568. new AggregateError([
  569. new AggregateError([
  570. new TypeError(`(slot1) Functions shouldn't be provided to slots`),
  571. new TypeError(`(slot2) Provide validate function instead of type: object`),
  572. ], `Errors in slot descriptions`),
  573. ], `Errors validating template description`));
  574. // 14: all intended types are supported
  575. t.doesNotThrow(
  576. () => Template.validateDescription({
  577. slots: {
  578. slot1: {type: 'string'},
  579. slot2: {type: 'number'},
  580. slot3: {type: 'bigint'},
  581. slot4: {type: 'boolean'},
  582. slot5: {type: 'symbol'},
  583. slot6: {type: 'html', mutable: false},
  584. },
  585. content: () => {},
  586. }));
  587. });
  588. t.test(`Template - slot value errors`, t => {
  589. t.plan(8);
  590. const template1 = html.template({
  591. slots: {
  592. basicString: {type: 'string'},
  593. basicNumber: {type: 'number'},
  594. basicBigint: {type: 'bigint'},
  595. basicBoolean: {type: 'boolean'},
  596. basicSymbol: {type: 'symbol'},
  597. basicHTML: {type: 'html', mutable: false},
  598. },
  599. content: slots =>
  600. html.tag('p', [
  601. `string: ${slots.basicString}`,
  602. `number: ${slots.basicNumber}`,
  603. `bigint: ${slots.basicBigint}`,
  604. `boolean: ${slots.basicBoolean}`,
  605. `symbol: ${slots.basicSymbol?.toString() ?? 'no symbol'}`,
  606. `html:`,
  607. slots.basicHTML,
  608. ]),
  609. });
  610. // 1-2: basic values match type, no error & reflected in content
  611. t.doesNotThrow(
  612. () => template1.setSlots({
  613. basicString: 'pingas',
  614. basicNumber: 123,
  615. basicBigint: 1234567891234567n,
  616. basicBoolean: true,
  617. basicSymbol: Symbol(`sup`),
  618. basicHTML: html.tag('span', `SnooPING AS usual, I see!`),
  619. }));
  620. t.equal(
  621. template1.toString(),
  622. html.tag('p', [
  623. `string: pingas`,
  624. `number: 123`,
  625. `bigint: 1234567891234567`,
  626. `boolean: true`,
  627. `symbol: Symbol(sup)`,
  628. `html:`,
  629. html.tag('span', `SnooPING AS usual, I see!`),
  630. ]).toString());
  631. // 3-4: null matches any type, no error & reflected in content
  632. t.doesNotThrow(
  633. () => template1.setSlots({
  634. basicString: null,
  635. basicNumber: null,
  636. basicBigint: null,
  637. basicBoolean: null,
  638. basicSymbol: null,
  639. basicHTML: null,
  640. }));
  641. t.equal(
  642. template1.toString(),
  643. html.tag('p', [
  644. `string: null`,
  645. `number: null`,
  646. `bigint: null`,
  647. `boolean: null`,
  648. `symbol: no symbol`,
  649. `html:`,
  650. ]).toString());
  651. // 5-6: type mismatch throws error, invalidates entire setSlots call
  652. template1.setSlots({
  653. basicString: 'pingas',
  654. basicNumber: 123,
  655. });
  656. strictlyThrows(t,
  657. () => template1.setSlots({
  658. basicBoolean: false,
  659. basicSymbol: `I'm not a symbol!`,
  660. }),
  661. new AggregateError([
  662. new TypeError(`(basicSymbol) Slot expects symbol, got string`),
  663. ], `Error validating template slots`))
  664. t.equal(
  665. template1.toString(),
  666. html.tag('p', [
  667. `string: pingas`,
  668. `number: 123`,
  669. `bigint: null`,
  670. `boolean: null`,
  671. `symbol: no symbol`,
  672. `html:`,
  673. ]).toString());
  674. const template2 = html.template({
  675. slots: {
  676. strictArrayOfStrings: {
  677. validate: v => v.strictArrayOf(v.isString),
  678. default: `Array Of Strings Fallback`.split(' '),
  679. },
  680. sparseArrayOfStrings: {
  681. validate: v => v.sparseArrayOf(v.isString),
  682. default: ['sparse', null, false, 'strings'],
  683. },
  684. arrayOfHTML: {
  685. validate: v => v.strictArrayOf(v.isHTML),
  686. default: [],
  687. },
  688. },
  689. content: slots =>
  690. html.tag('p', [
  691. html.tag('strong', slots.strictArrayOfStrings),
  692. `sparseArrayOfStrings length: ${slots.sparseArrayOfStrings.length}`,
  693. `arrayOfHTML length: ${slots.arrayOfHTML.length}`,
  694. ]),
  695. });
  696. // 7: isHTML behaves as it should, validate fails with validate throw
  697. strictlyThrows(t,
  698. () => template2.setSlots({
  699. strictArrayOfStrings: ['you got it', 'pingas', 0xdeadbeef],
  700. sparseArrayOfStrings: ['you got it', null, false, 'pingas'],
  701. arrayOfHTML: [
  702. html.tag('span'),
  703. html.template({content: () => 'dog'}),
  704. html.blank(),
  705. false && 'dogs',
  706. null,
  707. undefined,
  708. html.tags([
  709. html.tag('span', 'usual'),
  710. html.tag('span', 'i'),
  711. ]),
  712. ],
  713. }),
  714. new AggregateError([
  715. {
  716. name: 'AggregateError',
  717. message: /^\(strictArrayOfStrings\)/,
  718. errors: {length: 1},
  719. },
  720. ], `Error validating template slots`));
  721. // 8: default slot values respected
  722. t.equal(
  723. template2.toString(),
  724. html.tag('p', [
  725. html.tag('strong', [
  726. `Array`,
  727. `Of`,
  728. `Strings`,
  729. `Fallback`,
  730. ]),
  731. `sparseArrayOfStrings length: 4`,
  732. `arrayOfHTML length: 0`,
  733. ]).toString());
  734. });
  735. t.test(`Stationery`, t => {
  736. t.plan(3);
  737. // 1-3: basic behavior
  738. const stationery1 = new html.Stationery({
  739. slots: {
  740. slot1: {type: 'string', default: 'apricot'},
  741. slot2: {type: 'string', default: 'disaster'},
  742. },
  743. content: ({slot1, slot2}) => html.tag('span', `${slot1} ${slot2}`),
  744. });
  745. const template1 = stationery1.template();
  746. const template2 = stationery1.template();
  747. template2.setSlots({slot1: 'aquaduct', slot2: 'dichotomy'});
  748. const template3 = stationery1.template();
  749. template3.setSlots({slot2: 'vinaigrette'});
  750. t.equal(template1.toString(), `<span>apricot disaster</span>`);
  751. t.equal(template2.toString(), `<span>aquaduct dichotomy</span>`);
  752. t.equal(template3.toString(), `<span>apricot vinaigrette</span>`);
  753. });