ngwr 10.2.1 → 11.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.
- package/README.md +39 -19
- package/action-sheet/styles/_index.scss +4 -1
- package/anchor/styles/_index.scss +6 -5
- package/back-top/styles/_index.scss +6 -1
- package/blur-text/styles/_index.scss +13 -0
- package/button/styles/_index.scss +11 -6
- package/calendar/styles/_index.scss +11 -2
- package/carousel/styles/_index.scss +13 -2
- package/cascader/styles/_index.scss +5 -3
- package/circular-text/styles/_index.scss +2 -0
- package/collapse/styles/_index.scss +1 -1
- package/color-picker/styles/_index.scss +6 -1
- package/command-palette/styles/_index.scss +2 -1
- package/compare/styles/_index.scss +1 -2
- package/context-menu/styles/_index.scss +8 -2
- package/descriptions/styles/_index.scss +2 -2
- package/dialog/styles/_index.scss +7 -0
- package/drawer/styles/_index.scss +20 -1
- package/dropdown/styles/_index.scss +19 -5
- package/empty/styles/_index.scss +10 -0
- package/falling-text/styles/_index.scss +2 -1
- package/fesm2022/ngwr-action-sheet.mjs +13 -3
- package/fesm2022/ngwr-action-sheet.mjs.map +1 -1
- package/fesm2022/ngwr-affix.mjs +40 -13
- package/fesm2022/ngwr-affix.mjs.map +1 -1
- package/fesm2022/ngwr-anchor.mjs +18 -6
- package/fesm2022/ngwr-anchor.mjs.map +1 -1
- package/fesm2022/ngwr-aurora.mjs +2 -2
- package/fesm2022/ngwr-aurora.mjs.map +1 -1
- package/fesm2022/ngwr-avatar.mjs +31 -7
- package/fesm2022/ngwr-avatar.mjs.map +1 -1
- package/fesm2022/ngwr-back-top.mjs +6 -2
- package/fesm2022/ngwr-back-top.mjs.map +1 -1
- package/fesm2022/ngwr-badge.mjs +1 -1
- package/fesm2022/ngwr-badge.mjs.map +1 -1
- package/fesm2022/ngwr-bar-chart.mjs +30 -9
- package/fesm2022/ngwr-bar-chart.mjs.map +1 -1
- package/fesm2022/ngwr-blur-text.mjs +2 -2
- package/fesm2022/ngwr-blur-text.mjs.map +1 -1
- package/fesm2022/ngwr-border-glow.mjs +2 -2
- package/fesm2022/ngwr-border-glow.mjs.map +1 -1
- package/fesm2022/ngwr-breadcrumbs.mjs +2 -2
- package/fesm2022/ngwr-breadcrumbs.mjs.map +1 -1
- package/fesm2022/ngwr-burger.mjs +9 -4
- package/fesm2022/ngwr-burger.mjs.map +1 -1
- package/fesm2022/ngwr-button-testing.mjs +96 -0
- package/fesm2022/ngwr-button-testing.mjs.map +1 -0
- package/fesm2022/ngwr-button.mjs +31 -14
- package/fesm2022/ngwr-button.mjs.map +1 -1
- package/fesm2022/ngwr-calendar-heatmap.mjs +40 -8
- package/fesm2022/ngwr-calendar-heatmap.mjs.map +1 -1
- package/fesm2022/ngwr-calendar.mjs +20 -1
- package/fesm2022/ngwr-calendar.mjs.map +1 -1
- package/fesm2022/ngwr-card.mjs +2 -2
- package/fesm2022/ngwr-card.mjs.map +1 -1
- package/fesm2022/ngwr-carousel-testing.mjs +377 -0
- package/fesm2022/ngwr-carousel-testing.mjs.map +1 -0
- package/fesm2022/ngwr-carousel.mjs +67 -13
- package/fesm2022/ngwr-carousel.mjs.map +1 -1
- package/fesm2022/ngwr-cascader-testing.mjs +534 -0
- package/fesm2022/ngwr-cascader-testing.mjs.map +1 -0
- package/fesm2022/ngwr-cascader.mjs +2 -2
- package/fesm2022/ngwr-cascader.mjs.map +1 -1
- package/fesm2022/ngwr-checkbox-testing.mjs +90 -0
- package/fesm2022/ngwr-checkbox-testing.mjs.map +1 -0
- package/fesm2022/ngwr-checkbox.mjs +9 -3
- package/fesm2022/ngwr-checkbox.mjs.map +1 -1
- package/fesm2022/ngwr-circular-text.mjs +2 -2
- package/fesm2022/ngwr-circular-text.mjs.map +1 -1
- package/fesm2022/ngwr-click-spark.mjs +2 -2
- package/fesm2022/ngwr-click-spark.mjs.map +1 -1
- package/fesm2022/ngwr-collapse-testing.mjs +368 -0
- package/fesm2022/ngwr-collapse-testing.mjs.map +1 -0
- package/fesm2022/ngwr-color-picker.mjs +134 -14
- package/fesm2022/ngwr-color-picker.mjs.map +1 -1
- package/fesm2022/ngwr-command-palette-testing.mjs +589 -0
- package/fesm2022/ngwr-command-palette-testing.mjs.map +1 -0
- package/fesm2022/ngwr-command-palette.mjs +58 -8
- package/fesm2022/ngwr-command-palette.mjs.map +1 -1
- package/fesm2022/ngwr-compare.mjs +103 -13
- package/fesm2022/ngwr-compare.mjs.map +1 -1
- package/fesm2022/ngwr-config.mjs +106 -0
- package/fesm2022/ngwr-config.mjs.map +1 -0
- package/fesm2022/ngwr-context-menu-testing.mjs +631 -0
- package/fesm2022/ngwr-context-menu-testing.mjs.map +1 -0
- package/fesm2022/ngwr-context-menu.mjs +94 -8
- package/fesm2022/ngwr-context-menu.mjs.map +1 -1
- package/fesm2022/ngwr-cookie.mjs +5 -1
- package/fesm2022/ngwr-cookie.mjs.map +1 -1
- package/fesm2022/ngwr-counter.mjs +11 -3
- package/fesm2022/ngwr-counter.mjs.map +1 -1
- package/fesm2022/ngwr-date-adapter-luxon.mjs +52 -4
- package/fesm2022/ngwr-date-adapter-luxon.mjs.map +1 -1
- package/fesm2022/ngwr-date-adapter.mjs +54 -8
- package/fesm2022/ngwr-date-adapter.mjs.map +1 -1
- package/fesm2022/ngwr-date-picker-testing.mjs +766 -0
- package/fesm2022/ngwr-date-picker-testing.mjs.map +1 -0
- package/fesm2022/ngwr-date-picker.mjs +73 -3
- package/fesm2022/ngwr-date-picker.mjs.map +1 -1
- package/fesm2022/ngwr-decrypt-text.mjs +27 -9
- package/fesm2022/ngwr-decrypt-text.mjs.map +1 -1
- package/fesm2022/ngwr-dialog-testing.mjs +89 -0
- package/fesm2022/ngwr-dialog-testing.mjs.map +1 -0
- package/fesm2022/ngwr-directives.mjs +36 -12
- package/fesm2022/ngwr-directives.mjs.map +1 -1
- package/fesm2022/ngwr-donut-chart.mjs +22 -5
- package/fesm2022/ngwr-donut-chart.mjs.map +1 -1
- package/fesm2022/ngwr-drag-drop.mjs +2 -2
- package/fesm2022/ngwr-drag-drop.mjs.map +1 -1
- package/fesm2022/ngwr-drawer-testing.mjs +250 -0
- package/fesm2022/ngwr-drawer-testing.mjs.map +1 -0
- package/fesm2022/ngwr-dropdown-testing.mjs +291 -0
- package/fesm2022/ngwr-dropdown-testing.mjs.map +1 -0
- package/fesm2022/ngwr-dropdown.mjs +10 -2
- package/fesm2022/ngwr-dropdown.mjs.map +1 -1
- package/fesm2022/ngwr-empty.mjs +24 -7
- package/fesm2022/ngwr-empty.mjs.map +1 -1
- package/fesm2022/ngwr-event-calendar.mjs +57 -10
- package/fesm2022/ngwr-event-calendar.mjs.map +1 -1
- package/fesm2022/ngwr-falling-text.mjs +2 -2
- package/fesm2022/ngwr-falling-text.mjs.map +1 -1
- package/fesm2022/ngwr-file-upload-testing.mjs +332 -0
- package/fesm2022/ngwr-file-upload-testing.mjs.map +1 -0
- package/fesm2022/ngwr-form-testing.mjs +451 -0
- package/fesm2022/ngwr-form-testing.mjs.map +1 -0
- package/fesm2022/ngwr-form.mjs +19 -3
- package/fesm2022/ngwr-form.mjs.map +1 -1
- package/fesm2022/ngwr-fuzzy-text.mjs +9 -2
- package/fesm2022/ngwr-fuzzy-text.mjs.map +1 -1
- package/fesm2022/ngwr-gauge.mjs +19 -5
- package/fesm2022/ngwr-gauge.mjs.map +1 -1
- package/fesm2022/ngwr-glitch-text.mjs +2 -2
- package/fesm2022/ngwr-glitch-text.mjs.map +1 -1
- package/fesm2022/ngwr-gradient-text.mjs +2 -2
- package/fesm2022/ngwr-gradient-text.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-en.mjs +50 -0
- package/fesm2022/ngwr-i18n-en.mjs.map +1 -1
- package/fesm2022/ngwr-i18n-ru.mjs +48 -0
- package/fesm2022/ngwr-i18n-ru.mjs.map +1 -1
- package/fesm2022/ngwr-image-cropper.mjs +23 -0
- package/fesm2022/ngwr-image-cropper.mjs.map +1 -1
- package/fesm2022/ngwr-input-number-testing.mjs +306 -0
- package/fesm2022/ngwr-input-number-testing.mjs.map +1 -0
- package/fesm2022/ngwr-input-number.mjs +35 -3
- package/fesm2022/ngwr-input-number.mjs.map +1 -1
- package/fesm2022/ngwr-input-otp-testing.mjs +419 -0
- package/fesm2022/ngwr-input-otp-testing.mjs.map +1 -0
- package/fesm2022/ngwr-input-otp.mjs +36 -5
- package/fesm2022/ngwr-input-otp.mjs.map +1 -1
- package/fesm2022/ngwr-input-testing.mjs +102 -0
- package/fesm2022/ngwr-input-testing.mjs.map +1 -0
- package/fesm2022/ngwr-input.mjs +47 -10
- package/fesm2022/ngwr-input.mjs.map +1 -1
- package/fesm2022/ngwr-knob.mjs +44 -8
- package/fesm2022/ngwr-knob.mjs.map +1 -1
- package/fesm2022/ngwr-layout.mjs +21 -5
- package/fesm2022/ngwr-layout.mjs.map +1 -1
- package/fesm2022/ngwr-lightbox.mjs +33 -6
- package/fesm2022/ngwr-lightbox.mjs.map +1 -1
- package/fesm2022/ngwr-line-chart.mjs +17 -4
- package/fesm2022/ngwr-line-chart.mjs.map +1 -1
- package/fesm2022/ngwr-list.mjs +2 -2
- package/fesm2022/ngwr-list.mjs.map +1 -1
- package/fesm2022/ngwr-loading-bar.mjs +2 -2
- package/fesm2022/ngwr-loading-bar.mjs.map +1 -1
- package/fesm2022/ngwr-markdown-testing.mjs +516 -0
- package/fesm2022/ngwr-markdown-testing.mjs.map +1 -0
- package/fesm2022/ngwr-markdown.mjs +1492 -0
- package/fesm2022/ngwr-markdown.mjs.map +1 -0
- package/fesm2022/ngwr-marquee.mjs +17 -6
- package/fesm2022/ngwr-marquee.mjs.map +1 -1
- package/fesm2022/ngwr-mention-testing.mjs +513 -0
- package/fesm2022/ngwr-mention-testing.mjs.map +1 -0
- package/fesm2022/ngwr-mention.mjs +10 -1
- package/fesm2022/ngwr-mention.mjs.map +1 -1
- package/fesm2022/ngwr-meta.mjs +22 -7
- package/fesm2022/ngwr-meta.mjs.map +1 -1
- package/fesm2022/ngwr-meter-group.mjs +19 -5
- package/fesm2022/ngwr-meter-group.mjs.map +1 -1
- package/fesm2022/ngwr-overlay.mjs +41 -1
- package/fesm2022/ngwr-overlay.mjs.map +1 -1
- package/fesm2022/ngwr-pagination-testing.mjs +389 -0
- package/fesm2022/ngwr-pagination-testing.mjs.map +1 -0
- package/fesm2022/ngwr-pagination.mjs +14 -2
- package/fesm2022/ngwr-pagination.mjs.map +1 -1
- package/fesm2022/ngwr-popconfirm-testing.mjs +309 -0
- package/fesm2022/ngwr-popconfirm-testing.mjs.map +1 -0
- package/fesm2022/ngwr-popconfirm.mjs +95 -17
- package/fesm2022/ngwr-popconfirm.mjs.map +1 -1
- package/fesm2022/ngwr-popover-testing.mjs +316 -0
- package/fesm2022/ngwr-popover-testing.mjs.map +1 -0
- package/fesm2022/ngwr-popover.mjs +21 -3
- package/fesm2022/ngwr-popover.mjs.map +1 -1
- package/fesm2022/ngwr-pull-to-refresh.mjs +17 -2
- package/fesm2022/ngwr-pull-to-refresh.mjs.map +1 -1
- package/fesm2022/ngwr-qr.mjs +13 -3
- package/fesm2022/ngwr-qr.mjs.map +1 -1
- package/fesm2022/ngwr-radio-testing.mjs +392 -0
- package/fesm2022/ngwr-radio-testing.mjs.map +1 -0
- package/fesm2022/ngwr-radio.mjs +10 -3
- package/fesm2022/ngwr-radio.mjs.map +1 -1
- package/fesm2022/ngwr-rating-testing.mjs +384 -0
- package/fesm2022/ngwr-rating-testing.mjs.map +1 -0
- package/fesm2022/ngwr-rating.mjs +37 -2
- package/fesm2022/ngwr-rating.mjs.map +1 -1
- package/fesm2022/ngwr-segmented-testing.mjs +512 -0
- package/fesm2022/ngwr-segmented-testing.mjs.map +1 -0
- package/fesm2022/ngwr-select-testing.mjs +268 -0
- package/fesm2022/ngwr-select-testing.mjs.map +1 -0
- package/fesm2022/ngwr-select.mjs +87 -16
- package/fesm2022/ngwr-select.mjs.map +1 -1
- package/fesm2022/ngwr-shiny-text.mjs +2 -2
- package/fesm2022/ngwr-shiny-text.mjs.map +1 -1
- package/fesm2022/ngwr-sidebar.mjs +65 -12
- package/fesm2022/ngwr-sidebar.mjs.map +1 -1
- package/fesm2022/ngwr-slider-testing.mjs +478 -0
- package/fesm2022/ngwr-slider-testing.mjs.map +1 -0
- package/fesm2022/ngwr-slider.mjs +57 -13
- package/fesm2022/ngwr-slider.mjs.map +1 -1
- package/fesm2022/ngwr-sparkline.mjs +26 -6
- package/fesm2022/ngwr-sparkline.mjs.map +1 -1
- package/fesm2022/ngwr-speed-dial.mjs +37 -4
- package/fesm2022/ngwr-speed-dial.mjs.map +1 -1
- package/fesm2022/ngwr-spinner.mjs +12 -3
- package/fesm2022/ngwr-spinner.mjs.map +1 -1
- package/fesm2022/ngwr-splash-cursor.mjs +2 -2
- package/fesm2022/ngwr-splash-cursor.mjs.map +1 -1
- package/fesm2022/ngwr-split-text.mjs +2 -2
- package/fesm2022/ngwr-split-text.mjs.map +1 -1
- package/fesm2022/ngwr-splitter.mjs +70 -9
- package/fesm2022/ngwr-splitter.mjs.map +1 -1
- package/fesm2022/ngwr-spotlight-card.mjs +2 -2
- package/fesm2022/ngwr-spotlight-card.mjs.map +1 -1
- package/fesm2022/ngwr-star-border.mjs +2 -2
- package/fesm2022/ngwr-star-border.mjs.map +1 -1
- package/fesm2022/ngwr-statistic.mjs +24 -7
- package/fesm2022/ngwr-statistic.mjs.map +1 -1
- package/fesm2022/ngwr-stepper-testing.mjs +477 -0
- package/fesm2022/ngwr-stepper-testing.mjs.map +1 -0
- package/fesm2022/ngwr-switch-testing.mjs +77 -0
- package/fesm2022/ngwr-switch-testing.mjs.map +1 -0
- package/fesm2022/ngwr-switch.mjs +9 -3
- package/fesm2022/ngwr-switch.mjs.map +1 -1
- package/fesm2022/ngwr-table-testing.mjs +580 -0
- package/fesm2022/ngwr-table-testing.mjs.map +1 -0
- package/fesm2022/ngwr-table.mjs +87 -14
- package/fesm2022/ngwr-table.mjs.map +1 -1
- package/fesm2022/ngwr-tabs-testing.mjs +617 -0
- package/fesm2022/ngwr-tabs-testing.mjs.map +1 -0
- package/fesm2022/ngwr-tabs.mjs +38 -6
- package/fesm2022/ngwr-tabs.mjs.map +1 -1
- package/fesm2022/ngwr-textarea-testing.mjs +238 -0
- package/fesm2022/ngwr-textarea-testing.mjs.map +1 -0
- package/fesm2022/ngwr-textarea.mjs +32 -4
- package/fesm2022/ngwr-textarea.mjs.map +1 -1
- package/fesm2022/ngwr-tilt-card.mjs +2 -2
- package/fesm2022/ngwr-tilt-card.mjs.map +1 -1
- package/fesm2022/ngwr-toast-testing.mjs +101 -0
- package/fesm2022/ngwr-toast-testing.mjs.map +1 -0
- package/fesm2022/ngwr-toast.mjs +2 -2
- package/fesm2022/ngwr-toast.mjs.map +1 -1
- package/fesm2022/ngwr-tour.mjs +61 -9
- package/fesm2022/ngwr-tour.mjs.map +1 -1
- package/fesm2022/ngwr-transfer-testing.mjs +538 -0
- package/fesm2022/ngwr-transfer-testing.mjs.map +1 -0
- package/fesm2022/ngwr-transfer.mjs +45 -14
- package/fesm2022/ngwr-transfer.mjs.map +1 -1
- package/fesm2022/ngwr-tree-testing.mjs +734 -0
- package/fesm2022/ngwr-tree-testing.mjs.map +1 -0
- package/fesm2022/ngwr-tree.mjs +73 -7
- package/fesm2022/ngwr-tree.mjs.map +1 -1
- package/fesm2022/ngwr-typewriter.mjs +5 -3
- package/fesm2022/ngwr-typewriter.mjs.map +1 -1
- package/fesm2022/ngwr-version.mjs +1 -1
- package/fesm2022/ngwr-version.mjs.map +1 -1
- package/fesm2022/ngwr-virtual-scroll.mjs +31 -5
- package/fesm2022/ngwr-virtual-scroll.mjs.map +1 -1
- package/fesm2022/ngwr-waves.mjs +3 -3
- package/fesm2022/ngwr-waves.mjs.map +1 -1
- package/fesm2022/ngwr-window.mjs +8 -0
- package/fesm2022/ngwr-window.mjs.map +1 -1
- package/file-upload/styles/_index.scss +7 -2
- package/gauge/styles/_index.scss +1 -1
- package/glitch-text/styles/_index.scss +4 -0
- package/i18n/en.json +48 -0
- package/i18n/ru.json +48 -0
- package/image-cropper/styles/_index.scss +13 -1
- package/input/styles/_index.scss +2 -2
- package/input-number/styles/_index.scss +7 -2
- package/knob/styles/_index.scss +8 -1
- package/layout/styles/_index.scss +3 -3
- package/lightbox/styles/_index.scss +10 -2
- package/line-chart/styles/_index.scss +6 -0
- package/llms-full.txt +151 -4
- package/llms.txt +57 -8
- package/loading-bar/styles/_index.scss +1 -2
- package/markdown/styles/_index.scss +296 -0
- package/marquee/styles/_index.scss +12 -0
- package/mcp/api.js +512 -0
- package/mcp/catalog.js +157 -0
- package/mcp/server.js +273 -0
- package/mcp/tools.js +366 -0
- package/meter-group/styles/_index.scss +1 -1
- package/package.json +186 -35
- package/pagination/styles/_index.scss +6 -6
- package/popover/styles/_index.scss +37 -5
- package/rating/styles/_index.scss +10 -0
- package/result/styles/_index.scss +3 -1
- package/schematics/use/symbol-map.json +2 -0
- package/segmented/styles/_index.scss +12 -1
- package/select/styles/_index.scss +32 -10
- package/sidebar/styles/_index.scss +30 -5
- package/slider/styles/_index.scss +15 -0
- package/speed-dial/styles/_index.scss +20 -0
- package/split-text/styles/_index.scss +13 -0
- package/star-border/styles/_index.scss +2 -0
- package/stepper/styles/_index.scss +19 -3
- package/styles/_grid.scss +4 -4
- package/styles.scss +1 -0
- package/switch/styles/_index.scss +5 -3
- package/table/styles/_index.scss +25 -12
- package/tabs/styles/_index.scss +11 -1
- package/textarea/styles/_index.scss +5 -0
- package/theme/styles/_colors.scss +44 -15
- package/theme/styles/_dark.scss +35 -15
- package/theme/styles/_mixins.scss +3 -0
- package/timeline/styles/_index.scss +2 -2
- package/toast/styles/_index.scss +17 -8
- package/toolbar/styles/_index.scss +3 -3
- package/tour/styles/_index.scss +7 -1
- package/tree/styles/_index.scss +9 -5
- package/types/ngwr-action-sheet.d.ts +9 -1
- package/types/ngwr-affix.d.ts +7 -1
- package/types/ngwr-anchor.d.ts +8 -1
- package/types/ngwr-avatar.d.ts +13 -3
- package/types/ngwr-bar-chart.d.ts +16 -0
- package/types/ngwr-burger.d.ts +6 -2
- package/types/ngwr-button-testing.d.ts +85 -0
- package/types/ngwr-button.d.ts +26 -10
- package/types/ngwr-calendar-heatmap.d.ts +16 -2
- package/types/ngwr-calendar.d.ts +10 -0
- package/types/ngwr-carousel-testing.d.ts +257 -0
- package/types/ngwr-carousel.d.ts +20 -4
- package/types/ngwr-cascader-testing.d.ts +392 -0
- package/types/ngwr-checkbox-testing.d.ts +77 -0
- package/types/ngwr-checkbox.d.ts +7 -2
- package/types/ngwr-collapse-testing.d.ts +290 -0
- package/types/ngwr-color-picker.d.ts +20 -11
- package/types/ngwr-command-palette-testing.d.ts +437 -0
- package/types/ngwr-command-palette.d.ts +25 -2
- package/types/ngwr-compare.d.ts +50 -2
- package/types/ngwr-config.d.ts +176 -0
- package/types/ngwr-context-menu-testing.d.ts +401 -0
- package/types/ngwr-context-menu.d.ts +65 -17
- package/types/ngwr-counter.d.ts +6 -1
- package/types/ngwr-date-adapter-luxon.d.ts +26 -0
- package/types/ngwr-date-picker-testing.d.ts +518 -0
- package/types/ngwr-date-picker.d.ts +4 -0
- package/types/ngwr-dialog-testing.d.ts +79 -0
- package/types/ngwr-directives.d.ts +10 -1
- package/types/ngwr-donut-chart.d.ts +8 -1
- package/types/ngwr-drawer-testing.d.ts +196 -0
- package/types/ngwr-dropdown-testing.d.ts +202 -0
- package/types/ngwr-dropdown.d.ts +7 -1
- package/types/ngwr-empty.d.ts +19 -4
- package/types/ngwr-event-calendar.d.ts +26 -1
- package/types/ngwr-file-upload-testing.d.ts +194 -0
- package/types/ngwr-form-testing.d.ts +379 -0
- package/types/ngwr-form.d.ts +17 -0
- package/types/ngwr-gauge.d.ts +13 -1
- package/types/ngwr-image-cropper.d.ts +10 -0
- package/types/ngwr-input-number-testing.d.ts +226 -0
- package/types/ngwr-input-number.d.ts +29 -1
- package/types/ngwr-input-otp-testing.d.ts +299 -0
- package/types/ngwr-input-otp.d.ts +23 -2
- package/types/ngwr-input-testing.d.ts +84 -0
- package/types/ngwr-input.d.ts +20 -6
- package/types/ngwr-knob.d.ts +18 -0
- package/types/ngwr-layout.d.ts +1 -0
- package/types/ngwr-lightbox.d.ts +17 -3
- package/types/ngwr-line-chart.d.ts +9 -2
- package/types/ngwr-markdown-testing.d.ts +462 -0
- package/types/ngwr-markdown.d.ts +534 -0
- package/types/ngwr-marquee.d.ts +12 -2
- package/types/ngwr-mention-testing.d.ts +359 -0
- package/types/ngwr-meta.d.ts +4 -2
- package/types/ngwr-meter-group.d.ts +11 -1
- package/types/ngwr-overlay.d.ts +33 -2
- package/types/ngwr-pagination-testing.d.ts +291 -0
- package/types/ngwr-pagination.d.ts +1 -0
- package/types/ngwr-popconfirm-testing.d.ts +235 -0
- package/types/ngwr-popconfirm.d.ts +31 -21
- package/types/ngwr-popover-testing.d.ts +221 -0
- package/types/ngwr-popover.d.ts +9 -0
- package/types/ngwr-pull-to-refresh.d.ts +7 -0
- package/types/ngwr-qr.d.ts +9 -1
- package/types/ngwr-radio-testing.d.ts +296 -0
- package/types/ngwr-radio.d.ts +8 -2
- package/types/ngwr-rating-testing.d.ts +291 -0
- package/types/ngwr-rating.d.ts +23 -0
- package/types/ngwr-segmented-testing.d.ts +401 -0
- package/types/ngwr-select-testing.d.ts +175 -0
- package/types/ngwr-select.d.ts +45 -4
- package/types/ngwr-sidebar.d.ts +17 -2
- package/types/ngwr-slider-testing.d.ts +329 -0
- package/types/ngwr-slider.d.ts +35 -1
- package/types/ngwr-sparkline.d.ts +12 -2
- package/types/ngwr-speed-dial.d.ts +17 -18
- package/types/ngwr-spinner.d.ts +8 -1
- package/types/ngwr-splitter.d.ts +24 -1
- package/types/ngwr-statistic.d.ts +12 -2
- package/types/ngwr-stepper-testing.d.ts +346 -0
- package/types/ngwr-switch-testing.d.ts +66 -0
- package/types/ngwr-switch.d.ts +7 -2
- package/types/ngwr-table-testing.d.ts +423 -0
- package/types/ngwr-table.d.ts +42 -0
- package/types/ngwr-tabs-testing.d.ts +470 -0
- package/types/ngwr-tabs.d.ts +9 -0
- package/types/ngwr-textarea-testing.d.ts +184 -0
- package/types/ngwr-textarea.d.ts +7 -2
- package/types/ngwr-toast-testing.d.ts +80 -0
- package/types/ngwr-tour.d.ts +27 -0
- package/types/ngwr-transfer-testing.d.ts +444 -0
- package/types/ngwr-transfer.d.ts +16 -1
- package/types/ngwr-tree-testing.d.ts +501 -0
- package/types/ngwr-tree.d.ts +33 -0
- package/types/ngwr-version.d.ts +1 -1
- package/types/ngwr-virtual-scroll.d.ts +13 -1
- package/typewriter/styles/_index.scss +1 -1
- package/typography/styles/_index.scss +1 -1
- package/window/styles/_index.scss +32 -9
|
@@ -0,0 +1,1492 @@
|
|
|
1
|
+
import { coerceBooleanProperty } from '@angular/cdk/coercion';
|
|
2
|
+
import { NgTemplateOutlet } from '@angular/common';
|
|
3
|
+
import * as i0 from '@angular/core';
|
|
4
|
+
import { InjectionToken, inject, signal, Injectable, DestroyRef, input, computed, effect, ViewEncapsulation, Component, makeEnvironmentProviders } from '@angular/core';
|
|
5
|
+
import { useConfigValue } from 'ngwr/config';
|
|
6
|
+
import { WrCopyToClipboard } from 'ngwr/directives';
|
|
7
|
+
import { useI18nText } from 'ngwr/i18n';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @license
|
|
11
|
+
*
|
|
12
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
13
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Markdown to a node tree. No HTML anywhere in the pipeline.
|
|
17
|
+
*
|
|
18
|
+
* **Why a parser in a component library that has one runtime dependency.** The
|
|
19
|
+
* alternative was `marked` or `markdown-it`, and both produce an HTML string —
|
|
20
|
+
* which then has to reach the DOM through `[innerHTML]`, which means Angular's
|
|
21
|
+
* sanitizer on the hot path of the one component whose input is untrusted by
|
|
22
|
+
* definition, and `bypassSecurityTrustHtml` the moment syntax highlighting is
|
|
23
|
+
* wanted. A node tree renders through ordinary Angular bindings instead: every
|
|
24
|
+
* text node is a text node, every `href` goes through `SecurityContext.URL`, and
|
|
25
|
+
* nothing is ever parsed as HTML. That is worth a parser.
|
|
26
|
+
*
|
|
27
|
+
* The second reason is streaming, which no general-purpose parser is built for.
|
|
28
|
+
* See {@link WrMarkdownParseOptions.streaming}: an open fence has to render as a
|
|
29
|
+
* code block, and a half-typed `**` must not flash on screen as literal
|
|
30
|
+
* asterisks.
|
|
31
|
+
*
|
|
32
|
+
* **The subset, stated rather than discovered.** ATX headings, paragraphs,
|
|
33
|
+
* fenced code, blockquotes, bullet / ordered / task lists (nested), GFM tables,
|
|
34
|
+
* thematic breaks; inline code, strong, emphasis, strikethrough, links, images,
|
|
35
|
+
* autolinks (bracketed and bare), hard breaks, backslash escapes.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately absent, each for a reason:
|
|
38
|
+
*
|
|
39
|
+
* - **Raw HTML is escaped, never rendered.** A markdown renderer whose input is
|
|
40
|
+
* model output or user comments cannot pass `<img onerror>` through and call
|
|
41
|
+
* it a feature. `<div>` in equals `<div>` on screen, as text.
|
|
42
|
+
* - **Indented (four-space) code blocks.** In a document that also has nested
|
|
43
|
+
* lists, indentation is structural, and the ambiguity resolves against the
|
|
44
|
+
* author roughly every time: a wrapped list continuation becomes a code block.
|
|
45
|
+
* Fences are unambiguous, and fences are what generators emit.
|
|
46
|
+
* - **Setext headings** (`===` / `---` under a line). `---` is a thematic break
|
|
47
|
+
* here, always. The dual meaning is a well-known trap and the underline form
|
|
48
|
+
* is essentially unused in generated markdown.
|
|
49
|
+
* - **Reference links, footnotes, definition lists.** They need a second pass
|
|
50
|
+
* over a document that, mid-stream, is not all there yet.
|
|
51
|
+
*
|
|
52
|
+
* Two known deviations, kept rather than fixed, because the cure looked worse than
|
|
53
|
+
* the disease:
|
|
54
|
+
*
|
|
55
|
+
* - **A blank line inside a NESTED list marks the outer list loose too**, so
|
|
56
|
+
* `- a` / ` - b` / blank / ` - c` / `- d` double-spaces the outer items.
|
|
57
|
+
* CommonMark scopes looseness per list; telling the two apart needs to know
|
|
58
|
+
* which level a blank line belongs to, and the indentation alone does not say —
|
|
59
|
+
* content at the item's own indent can belong either to the item or to a
|
|
60
|
+
* sublist's sibling. The visible cost is spacing.
|
|
61
|
+
* - **An unmatched `[` is not linear.** The scan is bounded per opener
|
|
62
|
+
* ({@link MAX_LABEL_LENGTH}) and short-circuits once the source is known to hold
|
|
63
|
+
* no `]` at all, which took `'['.repeat(100000)` from 8s to 0.2s — but a
|
|
64
|
+
* document that is mostly open brackets with a `]` somewhere still pays per
|
|
65
|
+
* opener. Real prose does not have that shape.
|
|
66
|
+
*
|
|
67
|
+
* @param source markdown text
|
|
68
|
+
* @returns block nodes, ready to render
|
|
69
|
+
*/
|
|
70
|
+
function parseMarkdown(source, options = {}) {
|
|
71
|
+
if (!source)
|
|
72
|
+
return [];
|
|
73
|
+
const ctx = newContext(options.streaming ?? false);
|
|
74
|
+
const blocks = parseBlocks(normalize(source).split('\n'), 0, ctx);
|
|
75
|
+
return ctx.streaming ? applyStreamingTail(blocks) : blocks;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* How deep a quote / list nest may go before the parser stops recursing.
|
|
79
|
+
*
|
|
80
|
+
* Not a taste limit: each level is a recursive call over its own slice of the
|
|
81
|
+
* lines, so a pathological input (`> > > > …` a thousand deep, which is four
|
|
82
|
+
* kilobytes of text) is a stack overflow inside a change-detection pass. Past
|
|
83
|
+
* the cap the content renders as paragraphs — visible, harmless, and nothing a
|
|
84
|
+
* real document reaches.
|
|
85
|
+
*/
|
|
86
|
+
const MAX_DEPTH = 12;
|
|
87
|
+
/**
|
|
88
|
+
* How many columns of a table are rendered.
|
|
89
|
+
*
|
|
90
|
+
* The same class of protection as {@link MAX_DEPTH}, for the same reason, and it
|
|
91
|
+
* was measured rather than guessed: every row is padded to the header's width, so
|
|
92
|
+
* cells scale as columns × rows while the input scales as columns + rows. A 38 KB
|
|
93
|
+
* document — a 10,001-pipe header and 2,000 short rows, which is what a truncated
|
|
94
|
+
* table from a model looks like — produced **20 million cells** in 4.5 seconds,
|
|
95
|
+
* inside a `computed` that re-runs on every streamed chunk. No real table is wider
|
|
96
|
+
* than this; a wider one renders its first 128 columns.
|
|
97
|
+
*/
|
|
98
|
+
const MAX_TABLE_COLUMNS = 128;
|
|
99
|
+
function newContext(streaming) {
|
|
100
|
+
return {
|
|
101
|
+
streaming,
|
|
102
|
+
slugs: new Map(),
|
|
103
|
+
noCloser: new Map(),
|
|
104
|
+
noBracket: { from: Number.POSITIVE_INFINITY },
|
|
105
|
+
inLink: false,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const FENCE_RE = /^( {0,3})(`{3,}|~{3,})[ \t]*(.*)$/;
|
|
109
|
+
// The closing `#` run must be PRECEDED by whitespace, or `# C#` loses its sharp
|
|
110
|
+
// and `#######` (seven, not a heading in any dialect) matches as an empty h6.
|
|
111
|
+
const HEADING_RE = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/;
|
|
112
|
+
const RULE_RE = /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/;
|
|
113
|
+
const QUOTE_RE = /^ {0,3}>[ \t]?(.*)$/;
|
|
114
|
+
const LIST_RE = /^( *)([-*+]|\d{1,9}[.)])(?:([ \t]+)(.*)|[ \t]*)$/;
|
|
115
|
+
const TASK_RE = /^\[([ xX])\](?:[ \t]+(.*)|[ \t]*)$/;
|
|
116
|
+
const TABLE_DELIM_RE = /^ {0,3}\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/;
|
|
117
|
+
/**
|
|
118
|
+
* `\r\n` and lone `\r` out; tabs in the INDENT to four spaces, so indent maths is
|
|
119
|
+
* in one unit.
|
|
120
|
+
*
|
|
121
|
+
* The whole leading whitespace run, not just a line-initial tab: `' \t- b'` is
|
|
122
|
+
* two spaces and a tab, and `parseList` measures indentation in characters, so a
|
|
123
|
+
* tab counted as one column made mixed indentation parse as literal text.
|
|
124
|
+
*/
|
|
125
|
+
function normalize(source) {
|
|
126
|
+
return source.replace(/\r\n?/g, '\n').replace(/^[ \t]+/gm, indent => indent.replace(/\t/g, ' '));
|
|
127
|
+
}
|
|
128
|
+
/** Does this line begin a block that interrupts a paragraph or a list item? */
|
|
129
|
+
function startsBlock(line) {
|
|
130
|
+
return (FENCE_RE.test(line) || HEADING_RE.test(line) || RULE_RE.test(line) || QUOTE_RE.test(line) || LIST_RE.test(line));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Does this line end the paragraph it follows?
|
|
134
|
+
*
|
|
135
|
+
* Every block start does, except the two lists CommonMark says cannot interrupt
|
|
136
|
+
* one: an ordered item whose number is not 1, and an item with no content. The
|
|
137
|
+
* first rule exists for a single shape, and it is a shape prose actually has —
|
|
138
|
+
* "The year was" / "1986. It was a good year." must not become a list numbered
|
|
139
|
+
* from 1986.
|
|
140
|
+
*/
|
|
141
|
+
function interruptsParagraph(line) {
|
|
142
|
+
const list = matchListStart(line);
|
|
143
|
+
if (list && !RULE_RE.test(line)) {
|
|
144
|
+
return list.content.trim() !== '' && (!list.ordered || list.number === 1);
|
|
145
|
+
}
|
|
146
|
+
return startsBlock(line);
|
|
147
|
+
}
|
|
148
|
+
function matchListStart(line) {
|
|
149
|
+
const match = LIST_RE.exec(line);
|
|
150
|
+
if (!match)
|
|
151
|
+
return null;
|
|
152
|
+
const [, indent, marker, spaces, content] = match;
|
|
153
|
+
const ordered = !/^[-*+]$/.test(marker);
|
|
154
|
+
return {
|
|
155
|
+
indent: indent.length,
|
|
156
|
+
ordered,
|
|
157
|
+
number: ordered ? Number.parseInt(marker, 10) : 1,
|
|
158
|
+
content: content ?? '',
|
|
159
|
+
// An empty item (`-` alone) has no run of spaces to measure, so assume one.
|
|
160
|
+
contentIndent: indent.length + marker.length + (spaces?.length ?? 1),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function parseBlocks(lines, depth, ctx) {
|
|
164
|
+
if (depth >= MAX_DEPTH) {
|
|
165
|
+
const text = lines.join('\n').trim();
|
|
166
|
+
return text ? [{ kind: 'paragraph', inlines: inlines(text, 0, ctx) }] : [];
|
|
167
|
+
}
|
|
168
|
+
const out = [];
|
|
169
|
+
let i = 0;
|
|
170
|
+
while (i < lines.length) {
|
|
171
|
+
const line = lines[i];
|
|
172
|
+
if (!line.trim()) {
|
|
173
|
+
i++;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const fence = FENCE_RE.exec(line);
|
|
177
|
+
if (fence) {
|
|
178
|
+
const [, indent, marker, info] = fence;
|
|
179
|
+
const body = [];
|
|
180
|
+
const closer = new RegExp(`^ {0,3}\\${marker[0]}{${marker.length},}[ \\t]*$`);
|
|
181
|
+
let closed = false;
|
|
182
|
+
i++;
|
|
183
|
+
while (i < lines.length) {
|
|
184
|
+
if (closer.test(lines[i])) {
|
|
185
|
+
closed = true;
|
|
186
|
+
i++;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
// The opening fence's own indentation is not part of the code.
|
|
190
|
+
body.push(stripIndent(lines[i], indent.length));
|
|
191
|
+
i++;
|
|
192
|
+
}
|
|
193
|
+
out.push({
|
|
194
|
+
kind: 'code',
|
|
195
|
+
language: info.trim().split(/\s+/)[0].toLowerCase() || null,
|
|
196
|
+
code: body.join('\n'),
|
|
197
|
+
closed,
|
|
198
|
+
});
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const heading = HEADING_RE.exec(line);
|
|
202
|
+
if (heading) {
|
|
203
|
+
const text = (heading[2] ?? '').trim();
|
|
204
|
+
const content = inlines(text, 0, ctx);
|
|
205
|
+
out.push({
|
|
206
|
+
kind: 'heading',
|
|
207
|
+
level: heading[1].length,
|
|
208
|
+
inlines: content,
|
|
209
|
+
// Slugged from the rendered TEXT, not from the markdown: `## See [docs](url)`
|
|
210
|
+
// was producing `see-docshttpsxdeva`, an anchor nobody can link to.
|
|
211
|
+
id: uniqueSlug(plainText(content), ctx),
|
|
212
|
+
});
|
|
213
|
+
i++;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (RULE_RE.test(line)) {
|
|
217
|
+
out.push({ kind: 'rule' });
|
|
218
|
+
i++;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (QUOTE_RE.test(line)) {
|
|
222
|
+
const body = [];
|
|
223
|
+
while (i < lines.length) {
|
|
224
|
+
const quoted = QUOTE_RE.exec(lines[i]);
|
|
225
|
+
if (quoted) {
|
|
226
|
+
body.push(quoted[1]);
|
|
227
|
+
i++;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
// Lazy continuation: an unmarked, non-blank line still belongs to the
|
|
231
|
+
// quote unless it starts a block of its own.
|
|
232
|
+
if (!lines[i].trim() || startsBlock(lines[i]))
|
|
233
|
+
break;
|
|
234
|
+
body.push(lines[i].trimStart());
|
|
235
|
+
i++;
|
|
236
|
+
}
|
|
237
|
+
out.push({ kind: 'quote', children: parseBlocks(body, depth + 1, ctx) });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (matchListStart(line)) {
|
|
241
|
+
const [list, next] = parseList(lines, i, depth, ctx);
|
|
242
|
+
out.push(list);
|
|
243
|
+
i = next;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (isTableStart(lines, i)) {
|
|
247
|
+
const [table, next] = parseTable(lines, i, ctx);
|
|
248
|
+
out.push(table);
|
|
249
|
+
i = next;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const paragraph = [line];
|
|
253
|
+
i++;
|
|
254
|
+
while (i < lines.length && lines[i].trim() && !interruptsParagraph(lines[i])) {
|
|
255
|
+
// A delimiter row two lines in turns the paragraph into a table header.
|
|
256
|
+
if (isTableStart(lines, i))
|
|
257
|
+
break;
|
|
258
|
+
paragraph.push(lines[i]);
|
|
259
|
+
i++;
|
|
260
|
+
}
|
|
261
|
+
out.push({ kind: 'paragraph', inlines: inlines(paragraph.join('\n').trim(), 0, ctx) });
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* A table header, or a sentence that happens to contain a pipe.
|
|
267
|
+
*
|
|
268
|
+
* GFM's own rule, and the one this used to be missing: "the header row must match
|
|
269
|
+
* the delimiter row in the number of cells. If not, a table will not be
|
|
270
|
+
* recognized." Without the count check, `Use the | character` followed by `---`
|
|
271
|
+
* became a two-column table with an empty body — swallowing both the sentence and
|
|
272
|
+
* the thematic break, and contradicting this file's own decision that `---` is
|
|
273
|
+
* always a rule.
|
|
274
|
+
*/
|
|
275
|
+
function isTableStart(lines, at) {
|
|
276
|
+
const header = lines[at];
|
|
277
|
+
const delimiter = lines[at + 1];
|
|
278
|
+
if (!header?.includes('|') || delimiter === undefined || !TABLE_DELIM_RE.test(delimiter))
|
|
279
|
+
return false;
|
|
280
|
+
return splitRow(header).length === splitRow(delimiter).length;
|
|
281
|
+
}
|
|
282
|
+
/** Remove up to `width` leading spaces — never more, so relative indent survives. */
|
|
283
|
+
function stripIndent(line, width) {
|
|
284
|
+
let cut = 0;
|
|
285
|
+
while (cut < width && line[cut] === ' ')
|
|
286
|
+
cut++;
|
|
287
|
+
return line.slice(cut);
|
|
288
|
+
}
|
|
289
|
+
function parseList(lines, from, depth, ctx) {
|
|
290
|
+
const first = matchListStart(lines[from]);
|
|
291
|
+
const items = [];
|
|
292
|
+
let loose = false;
|
|
293
|
+
let i = from;
|
|
294
|
+
while (i < lines.length) {
|
|
295
|
+
// A thematic break wins over a list item, which is CommonMark's rule and not
|
|
296
|
+
// a nicety: `* * *` matches the bullet pattern, so without this `* a`,
|
|
297
|
+
// `* * *`, `* b` is one list with a phantom nesting level where the divider
|
|
298
|
+
// was, and the `<hr>` is gone.
|
|
299
|
+
if (RULE_RE.test(lines[i]))
|
|
300
|
+
break;
|
|
301
|
+
const start = matchListStart(lines[i]);
|
|
302
|
+
// A different marker family starts a NEW list rather than continuing this
|
|
303
|
+
// one — `- a` then `1. b` is two lists, and rendering it as one silently
|
|
304
|
+
// renumbers the author's content.
|
|
305
|
+
if (start?.ordered !== first.ordered || start.indent > first.indent + 3)
|
|
306
|
+
break;
|
|
307
|
+
const body = [start.content];
|
|
308
|
+
let blanks = 0;
|
|
309
|
+
i++;
|
|
310
|
+
while (i < lines.length) {
|
|
311
|
+
const line = lines[i];
|
|
312
|
+
if (!line.trim()) {
|
|
313
|
+
blanks++;
|
|
314
|
+
i++;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const indent = line.length - line.trimStart().length;
|
|
318
|
+
if (indent >= start.contentIndent) {
|
|
319
|
+
// A blank line followed by more of the same item makes the list loose,
|
|
320
|
+
// which is what decides whether items get `<p>` wrappers.
|
|
321
|
+
if (blanks > 0) {
|
|
322
|
+
body.push(...Array(blanks).fill(''));
|
|
323
|
+
loose = true;
|
|
324
|
+
blanks = 0;
|
|
325
|
+
}
|
|
326
|
+
body.push(stripIndent(line, start.contentIndent));
|
|
327
|
+
i++;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
// Out-dented: a sibling item, another block, or lazy continuation of this
|
|
331
|
+
// item's paragraph. After a blank line only a sibling can follow.
|
|
332
|
+
if (blanks > 0 || matchListStart(line) || startsBlock(line))
|
|
333
|
+
break;
|
|
334
|
+
body.push(line.trimStart());
|
|
335
|
+
i++;
|
|
336
|
+
}
|
|
337
|
+
const nextIsSibling = i < lines.length && matchListStart(lines[i]) !== null;
|
|
338
|
+
if (blanks > 0 && nextIsSibling)
|
|
339
|
+
loose = true;
|
|
340
|
+
items.push(makeItem(body, depth, ctx));
|
|
341
|
+
if (blanks > 0 && !nextIsSibling)
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
return [{ kind: 'list', ordered: first.ordered, start: first.number, tight: !loose, items }, i];
|
|
345
|
+
}
|
|
346
|
+
function makeItem(body, depth, ctx) {
|
|
347
|
+
const task = TASK_RE.exec(body[0] ?? '');
|
|
348
|
+
const lines = task ? [task[2] ?? '', ...body.slice(1)] : body;
|
|
349
|
+
return {
|
|
350
|
+
children: parseBlocks(lines, depth + 1, ctx),
|
|
351
|
+
checked: task ? task[1].toLowerCase() === 'x' : null,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function parseTable(lines, from, ctx) {
|
|
355
|
+
const head = splitRow(lines[from]).slice(0, MAX_TABLE_COLUMNS);
|
|
356
|
+
const align = splitRow(lines[from + 1]).map((spec) => {
|
|
357
|
+
const left = spec.startsWith(':');
|
|
358
|
+
const right = spec.endsWith(':');
|
|
359
|
+
if (left && right)
|
|
360
|
+
return 'center';
|
|
361
|
+
if (right)
|
|
362
|
+
return 'end';
|
|
363
|
+
if (left)
|
|
364
|
+
return 'start';
|
|
365
|
+
return null;
|
|
366
|
+
});
|
|
367
|
+
const rows = [];
|
|
368
|
+
let i = from + 2;
|
|
369
|
+
while (i < lines.length) {
|
|
370
|
+
const line = lines[i];
|
|
371
|
+
// GFM ends a table at a blank line, a line that starts another block, or
|
|
372
|
+
// anything without a cell divider.
|
|
373
|
+
if (!line.trim() || !line.includes('|') || startsBlock(line))
|
|
374
|
+
break;
|
|
375
|
+
rows.push(normalizeCells(splitRow(line), head.length, ctx));
|
|
376
|
+
i++;
|
|
377
|
+
}
|
|
378
|
+
return [
|
|
379
|
+
{
|
|
380
|
+
kind: 'table',
|
|
381
|
+
head: head.map(cell => ({ inlines: inlines(cell, 0, ctx) })),
|
|
382
|
+
rows,
|
|
383
|
+
align: normalizeAlign(align, head.length),
|
|
384
|
+
},
|
|
385
|
+
i,
|
|
386
|
+
];
|
|
387
|
+
}
|
|
388
|
+
/** Split on unescaped pipes, dropping the optional leading and trailing one. */
|
|
389
|
+
function splitRow(line) {
|
|
390
|
+
const cells = [];
|
|
391
|
+
let current = '';
|
|
392
|
+
for (let i = 0; i < line.length; i++) {
|
|
393
|
+
if (line[i] === '\\' && line[i + 1] === '|') {
|
|
394
|
+
current += '|';
|
|
395
|
+
i++;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (line[i] === '|') {
|
|
399
|
+
cells.push(current);
|
|
400
|
+
current = '';
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
current += line[i];
|
|
404
|
+
}
|
|
405
|
+
cells.push(current);
|
|
406
|
+
if (cells.length > 1 && !cells[0].trim())
|
|
407
|
+
cells.shift();
|
|
408
|
+
if (cells.length > 1 && !cells[cells.length - 1].trim())
|
|
409
|
+
cells.pop();
|
|
410
|
+
return cells.map(cell => cell.trim());
|
|
411
|
+
}
|
|
412
|
+
/** A row shorter than the header gets empty cells; a longer one is truncated. */
|
|
413
|
+
function normalizeCells(cells, width, ctx) {
|
|
414
|
+
return Array.from({ length: width }, (_, index) => ({ inlines: inlines(cells[index] ?? '', 0, ctx) }));
|
|
415
|
+
}
|
|
416
|
+
function normalizeAlign(align, width) {
|
|
417
|
+
return Array.from({ length: width }, (_, index) => align[index] ?? null);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* GitHub-compatible heading slug: lowercase, punctuation dropped, spaces to dashes.
|
|
421
|
+
*
|
|
422
|
+
* `_` and `-` are KEPT, which is what GitHub does — stripping the underscore broke
|
|
423
|
+
* every published `#snake_case` anchor.
|
|
424
|
+
*/
|
|
425
|
+
function slug(text) {
|
|
426
|
+
return text
|
|
427
|
+
.toLowerCase()
|
|
428
|
+
.replace(/[^\p{L}\p{N}\s_-]/gu, '')
|
|
429
|
+
.trim()
|
|
430
|
+
.replace(/\s+/g, '-');
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* The slug, with a counter when the document repeats a title.
|
|
434
|
+
*
|
|
435
|
+
* Two `## Fixes` in one changelog produced two `id="fixes"`, which is invalid and
|
|
436
|
+
* makes `#fixes` resolve to whichever came first. GitHub appends `-1`, `-2`; so
|
|
437
|
+
* does this. An empty slug (a heading of pure emoji, or no text at all) stays
|
|
438
|
+
* empty and the renderer binds no `id`.
|
|
439
|
+
*/
|
|
440
|
+
function uniqueSlug(text, ctx) {
|
|
441
|
+
const base = slug(text);
|
|
442
|
+
if (!base)
|
|
443
|
+
return '';
|
|
444
|
+
const used = ctx.slugs.get(base) ?? 0;
|
|
445
|
+
ctx.slugs.set(base, used + 1);
|
|
446
|
+
return used === 0 ? base : `${base}-${used}`;
|
|
447
|
+
}
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Inline
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Every ASCII punctuation character, which is what CommonMark allows. The
|
|
452
|
+
// hand-written subset left `\$`, `\&`, `\%` and eight others rendering with their
|
|
453
|
+
// backslash — and `\$` is what generators emit to keep a dollar out of math mode.
|
|
454
|
+
const ESCAPABLE = /[!-/:-@[-`{-~]/;
|
|
455
|
+
const SCHEME_RE = /^([a-z][a-z0-9+.-]*):/i;
|
|
456
|
+
const ALLOWED_SCHEMES = new Set(['http', 'https', 'mailto', 'tel', 'ftp']);
|
|
457
|
+
const DATA_IMAGE_RE = /^data:image\/(?:png|jpe?g|gif|webp|avif);base64,[a-z0-9+/=]+$/i;
|
|
458
|
+
const AUTOLINK_RE = /^<((?:https?|mailto|tel):[^>\s]+)>/i;
|
|
459
|
+
/** `<destination>` — markdown's way of putting a space in a URL. */
|
|
460
|
+
const ANGLE_DEST_RE = /^<([^<>]*)>(?:\s+["'(](.*)["')])?$/;
|
|
461
|
+
/** CommonMark's own cap on a link label, which is also what bounds the scan. */
|
|
462
|
+
const MAX_LABEL_LENGTH = 999;
|
|
463
|
+
const BARE_URL_RE = /^https?:\/\/[^\s<>[\]()]*(?:\([^\s<>()]*\)[^\s<>[\]()]*)*/i;
|
|
464
|
+
const EMAIL_AUTOLINK_RE = /^<([^\s@<>]+@[^\s@<>.]+\.[^\s@<>]+)>/;
|
|
465
|
+
/**
|
|
466
|
+
* A URL safe to put in `href` / `src`, or `null` to render the link as plain text.
|
|
467
|
+
*
|
|
468
|
+
* Two layers, and both are wanted. Angular sanitizes `[href]` bindings itself,
|
|
469
|
+
* but its answer to a hostile URL is the string `unsafe:javascript:…` in a live
|
|
470
|
+
* anchor — a link that looks real, is announced as a link, and does nothing.
|
|
471
|
+
* Refusing it here means the label renders as text instead, which is honest.
|
|
472
|
+
*
|
|
473
|
+
* Whitespace and control characters are stripped before the scheme is read,
|
|
474
|
+
* because `java\nscript:` and `java script:` are the same URL to a browser and
|
|
475
|
+
* different strings to a naive check.
|
|
476
|
+
*/
|
|
477
|
+
function safeMarkdownUrl(raw, kind) {
|
|
478
|
+
const url = raw.trim();
|
|
479
|
+
// Whitespace and control characters only: a hyphen has no business being
|
|
480
|
+
// stripped out of a host name. Written as escapes rather than literal bytes —
|
|
481
|
+
// a raw NUL inside a regex literal makes the file binary to every text tool.
|
|
482
|
+
// Stripping control characters is the entire point here: a browser ignores them
|
|
483
|
+
// before resolving the scheme, so a check that reads the raw string sees a
|
|
484
|
+
// relative path and waves `java\u0000script:` straight through.
|
|
485
|
+
// eslint-disable-next-line no-control-regex
|
|
486
|
+
const bare = url.replace(/[\s\u0000-\u001f\u007f]/g, '');
|
|
487
|
+
const scheme = SCHEME_RE.exec(bare);
|
|
488
|
+
// No scheme at all: relative path, `#anchor`, `//host` — nothing executable.
|
|
489
|
+
if (!scheme)
|
|
490
|
+
return url;
|
|
491
|
+
const name = scheme[1].toLowerCase();
|
|
492
|
+
// `data:` is an image-only allowance, and only for raster types: an SVG can
|
|
493
|
+
// carry script, and `data:text/html` is a same-origin document.
|
|
494
|
+
if (name === 'data')
|
|
495
|
+
return kind === 'image' && DATA_IMAGE_RE.test(bare) ? bare : null;
|
|
496
|
+
return ALLOWED_SCHEMES.has(name) ? url : null;
|
|
497
|
+
}
|
|
498
|
+
/** Inline markdown to nodes. */
|
|
499
|
+
function parseInlines(source, options = {}) {
|
|
500
|
+
return inlines(source, 0, newContext(options.streaming ?? false));
|
|
501
|
+
}
|
|
502
|
+
function inlines(source, depth, ctx) {
|
|
503
|
+
if (depth >= MAX_DEPTH)
|
|
504
|
+
return source ? [{ kind: 'text', value: source }] : [];
|
|
505
|
+
const out = [];
|
|
506
|
+
let text = '';
|
|
507
|
+
let i = 0;
|
|
508
|
+
const flush = () => {
|
|
509
|
+
if (text) {
|
|
510
|
+
out.push({ kind: 'text', value: text });
|
|
511
|
+
text = '';
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
while (i < source.length) {
|
|
515
|
+
const char = source[i];
|
|
516
|
+
if (char === '\\') {
|
|
517
|
+
// A backslash before a newline is a hard break; before punctuation, an escape.
|
|
518
|
+
if (source[i + 1] === '\n') {
|
|
519
|
+
flush();
|
|
520
|
+
out.push({ kind: 'break' });
|
|
521
|
+
i += 2;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (i + 1 < source.length && ESCAPABLE.test(source[i + 1])) {
|
|
525
|
+
text += source[i + 1];
|
|
526
|
+
i += 2;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (char === '\n') {
|
|
531
|
+
// Two trailing spaces are markdown's hard break. `endsWith`, not
|
|
532
|
+
// `/[ ]{2,}$/.test(text)`: that regex has no start anchor, so it retries
|
|
533
|
+
// from every position of the accumulated paragraph, which made paragraph
|
|
534
|
+
// parsing quadratic. On a 161 KB document a CPU profile put 3306 ms of 3378
|
|
535
|
+
// ms of total self-time in that one test — and it is re-paid on every
|
|
536
|
+
// streamed chunk.
|
|
537
|
+
const hard = text.endsWith(' ');
|
|
538
|
+
if (hard) {
|
|
539
|
+
text = text.replace(/[ ]+$/, '');
|
|
540
|
+
flush();
|
|
541
|
+
out.push({ kind: 'break' });
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
// A soft break is whitespace; CSS collapses it like any other.
|
|
545
|
+
text += '\n';
|
|
546
|
+
}
|
|
547
|
+
i++;
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (char === '`') {
|
|
551
|
+
const run = /^`+/.exec(source.slice(i))[0];
|
|
552
|
+
const close = findCodeClose(source, i + run.length, run);
|
|
553
|
+
if (close !== -1) {
|
|
554
|
+
flush();
|
|
555
|
+
out.push({ kind: 'code', value: codeSpanValue(source.slice(i + run.length, close)) });
|
|
556
|
+
i = close + run.length;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (char === '<') {
|
|
561
|
+
const auto = AUTOLINK_RE.exec(source.slice(i));
|
|
562
|
+
if (auto) {
|
|
563
|
+
// `ctx.inLink`: CommonMark forbids a link inside a link, and nothing else
|
|
564
|
+
// stops it here — a tree built with DOM APIs nests the `<a>` an HTML
|
|
565
|
+
// parser would have thrown away, which is invalid markup and an axe
|
|
566
|
+
// `nested-interactive` violation at serious level.
|
|
567
|
+
const href = ctx.inLink ? null : safeMarkdownUrl(auto[1], 'link');
|
|
568
|
+
flush();
|
|
569
|
+
out.push(href
|
|
570
|
+
? { kind: 'link', href, title: null, children: [{ kind: 'text', value: auto[1] }] }
|
|
571
|
+
: { kind: 'text', value: auto[1] });
|
|
572
|
+
i += auto[0].length;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
const email = EMAIL_AUTOLINK_RE.exec(source.slice(i));
|
|
576
|
+
if (email) {
|
|
577
|
+
// Through `safeMarkdownUrl` like every other href: built by hand, this was
|
|
578
|
+
// the one destination that skipped the control-character strip, which the
|
|
579
|
+
// component's own JSDoc promises for all of them.
|
|
580
|
+
const href = ctx.inLink ? null : safeMarkdownUrl(`mailto:${email[1]}`, 'link');
|
|
581
|
+
flush();
|
|
582
|
+
out.push(href
|
|
583
|
+
? { kind: 'link', href, title: null, children: [{ kind: 'text', value: email[1] }] }
|
|
584
|
+
: { kind: 'text', value: email[1] });
|
|
585
|
+
i += email[0].length;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (char === '!' && source[i + 1] === '[') {
|
|
590
|
+
const link = matchLink(source, i + 1, ctx);
|
|
591
|
+
if (link) {
|
|
592
|
+
const src = safeMarkdownUrl(link.href, 'image');
|
|
593
|
+
const alt = plainText(inlines(link.label, depth + 1, { ...ctx, inLink: true }));
|
|
594
|
+
flush();
|
|
595
|
+
// A refused source leaves the alt text, which is the information the
|
|
596
|
+
// author actually wrote.
|
|
597
|
+
out.push(src ? { kind: 'image', src, alt, title: link.title } : { kind: 'text', value: alt });
|
|
598
|
+
i = link.end;
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (char === '[') {
|
|
603
|
+
const link = matchLink(source, i, ctx);
|
|
604
|
+
if (link) {
|
|
605
|
+
const href = ctx.inLink ? null : safeMarkdownUrl(link.href, 'link');
|
|
606
|
+
// The label is parsed with links SUPPRESSED, so an inner one renders as
|
|
607
|
+
// its own text — CommonMark's answer to `[a [b](c)](d)`.
|
|
608
|
+
const children = inlines(link.label, depth + 1, { ...ctx, inLink: true });
|
|
609
|
+
flush();
|
|
610
|
+
if (href)
|
|
611
|
+
out.push({ kind: 'link', href, title: link.title, children });
|
|
612
|
+
else
|
|
613
|
+
out.push(...children);
|
|
614
|
+
i = link.end;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if ((char === 'h' || char === 'H') && (i === 0 || !/[\w/]/.test(source[i - 1]))) {
|
|
619
|
+
const bare = ctx.inLink ? null : BARE_URL_RE.exec(source.slice(i));
|
|
620
|
+
if (bare) {
|
|
621
|
+
const url = trimUrlPunctuation(bare[0]);
|
|
622
|
+
const href = safeMarkdownUrl(url, 'link');
|
|
623
|
+
if (href) {
|
|
624
|
+
flush();
|
|
625
|
+
out.push({ kind: 'link', href, title: null, children: [{ kind: 'text', value: url }] });
|
|
626
|
+
i += url.length;
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (char === '*' || char === '_' || char === '~') {
|
|
632
|
+
const emphasis = matchEmphasis(source, i, depth, ctx);
|
|
633
|
+
if (emphasis) {
|
|
634
|
+
flush();
|
|
635
|
+
out.push(emphasis.node);
|
|
636
|
+
i = emphasis.end;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
text += char;
|
|
641
|
+
i++;
|
|
642
|
+
}
|
|
643
|
+
flush();
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
/** The closing backtick run of exactly the opener's length — a longer run is not it. */
|
|
647
|
+
function findCodeClose(source, from, run) {
|
|
648
|
+
for (let i = from; i < source.length; i++) {
|
|
649
|
+
if (source[i] !== '`')
|
|
650
|
+
continue;
|
|
651
|
+
let end = i;
|
|
652
|
+
while (source[end] === '`')
|
|
653
|
+
end++;
|
|
654
|
+
if (end - i === run.length)
|
|
655
|
+
return i;
|
|
656
|
+
i = end - 1;
|
|
657
|
+
}
|
|
658
|
+
return -1;
|
|
659
|
+
}
|
|
660
|
+
/** CommonMark strips one space from each end when both are present. */
|
|
661
|
+
function codeSpanValue(raw) {
|
|
662
|
+
const value = raw.replace(/\n/g, ' ');
|
|
663
|
+
return value.length > 2 && value.startsWith(' ') && value.endsWith(' ') && value.trim() ? value.slice(1, -1) : value;
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* `[label](href "title")` starting at `source[at] === '['`.
|
|
667
|
+
*
|
|
668
|
+
* Two bounds, both load-bearing. The label scan stops at
|
|
669
|
+
* {@link MAX_LABEL_LENGTH}, which is CommonMark's own limit, and a scan that
|
|
670
|
+
* reaches the end of the source without meeting a single `]` records the fact in
|
|
671
|
+
* the context — from then on every later `[` answers in constant time. Without
|
|
672
|
+
* that, `'['.repeat(100000)` spent 8 seconds on the main thread, inside a
|
|
673
|
+
* `computed`.
|
|
674
|
+
*/
|
|
675
|
+
function matchLink(source, at, ctx) {
|
|
676
|
+
if (at >= ctx.noBracket.from)
|
|
677
|
+
return null;
|
|
678
|
+
let depth = 0;
|
|
679
|
+
let close = -1;
|
|
680
|
+
let sawClose = false;
|
|
681
|
+
let scannedToEnd = true;
|
|
682
|
+
for (let i = at; i < source.length; i++) {
|
|
683
|
+
if (i - at > MAX_LABEL_LENGTH) {
|
|
684
|
+
scannedToEnd = false;
|
|
685
|
+
break;
|
|
686
|
+
}
|
|
687
|
+
const char = source[i];
|
|
688
|
+
if (char === '\\') {
|
|
689
|
+
i++;
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
// A code span can hold an unbalanced bracket; stepping over it keeps
|
|
693
|
+
// `` [`a]b`](x) `` from ending the label early.
|
|
694
|
+
if (char === '`') {
|
|
695
|
+
const run = /^`+/.exec(source.slice(i))[0];
|
|
696
|
+
const end = findCodeClose(source, i + run.length, run);
|
|
697
|
+
if (end !== -1) {
|
|
698
|
+
i = end + run.length - 1;
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (char === '[')
|
|
703
|
+
depth++;
|
|
704
|
+
if (char === ']') {
|
|
705
|
+
sawClose = true;
|
|
706
|
+
depth--;
|
|
707
|
+
if (depth === 0) {
|
|
708
|
+
close = i;
|
|
709
|
+
break;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (close === -1) {
|
|
714
|
+
// Only when the source truly holds no `]` past here: an unbalanced nest
|
|
715
|
+
// (`[[a](b)`) fails for a different reason, and memoising that would hide the
|
|
716
|
+
// valid link one character to the right.
|
|
717
|
+
if (!sawClose && scannedToEnd)
|
|
718
|
+
ctx.noBracket.from = Math.min(ctx.noBracket.from, at);
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
if (source[close + 1] !== '(')
|
|
722
|
+
return null;
|
|
723
|
+
let parens = 1;
|
|
724
|
+
let end = -1;
|
|
725
|
+
for (let i = close + 2; i < source.length; i++) {
|
|
726
|
+
const char = source[i];
|
|
727
|
+
if (char === '\\') {
|
|
728
|
+
i++;
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
if (char === '(')
|
|
732
|
+
parens++;
|
|
733
|
+
if (char === ')') {
|
|
734
|
+
parens--;
|
|
735
|
+
if (parens === 0) {
|
|
736
|
+
end = i;
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// A newline inside the destination means this was never a link.
|
|
741
|
+
if (char === '\n')
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
if (end === -1)
|
|
745
|
+
return null;
|
|
746
|
+
const target = source.slice(close + 2, end).trim();
|
|
747
|
+
const label = source.slice(at + 1, close);
|
|
748
|
+
// `<…>` around the destination is markdown's way of allowing a space in it, and
|
|
749
|
+
// it has to be unwrapped BEFORE the scheme is read: `[a](<javascript:alert(1)>)`
|
|
750
|
+
// has no scheme while the bracket is still attached, so `safeMarkdownUrl` read
|
|
751
|
+
// it as a relative path and waved it through — leaving Angular's sanitizer, the
|
|
752
|
+
// layer this file calls insufficient, as the only thing standing in the way.
|
|
753
|
+
const angled = ANGLE_DEST_RE.exec(target);
|
|
754
|
+
if (angled)
|
|
755
|
+
return { label, href: angled[1], title: angled[2] ?? null, end: end + 1 };
|
|
756
|
+
const titled = /^(\S*)\s+["'(](.*)["')]$/.exec(target);
|
|
757
|
+
return {
|
|
758
|
+
label,
|
|
759
|
+
href: titled ? titled[1] : target,
|
|
760
|
+
title: titled ? titled[2] : null,
|
|
761
|
+
end: end + 1,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
/** Trailing sentence punctuation belongs to the prose, not to the bare URL. */
|
|
765
|
+
function trimUrlPunctuation(url) {
|
|
766
|
+
return url.replace(/[.,;:!?'"]+$/, '');
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Emphasis, strong and strikethrough.
|
|
770
|
+
*
|
|
771
|
+
* A pragmatic reading of CommonMark's flanking rules rather than the full
|
|
772
|
+
* algorithm: an opener may not be followed by whitespace, a closer may not be
|
|
773
|
+
* preceded by it, and `_` additionally may not sit against a word character on
|
|
774
|
+
* the outside. That last clause is the one that matters in practice — without it
|
|
775
|
+
* `snake_case_name` renders as `snake<em>case</em>name`, which is how most
|
|
776
|
+
* lightweight parsers embarrass themselves on code-adjacent prose.
|
|
777
|
+
*/
|
|
778
|
+
function matchEmphasis(source, at, depth, ctx) {
|
|
779
|
+
const char = source[at];
|
|
780
|
+
const run = /^(\*+|_+|~+)/.exec(source.slice(at))[0].length;
|
|
781
|
+
// `~` is strikethrough only as a pair: a single one is arithmetic, a path, or
|
|
782
|
+
// LaTeX far more often than it is emphasis.
|
|
783
|
+
if (char === '~' && run < 2)
|
|
784
|
+
return null;
|
|
785
|
+
const use = char === '~' ? 2 : Math.min(run, 2);
|
|
786
|
+
const after = source[at + run];
|
|
787
|
+
if (after === undefined || /\s/.test(after))
|
|
788
|
+
return null;
|
|
789
|
+
if (char === '_' && at > 0 && /[\p{L}\p{N}]/u.test(source[at - 1]))
|
|
790
|
+
return null;
|
|
791
|
+
// Already known to have no closer from here on. The search is monotone — the
|
|
792
|
+
// window for a later opener is a subset of this one — so one failure answers
|
|
793
|
+
// every subsequent opener of the same character, which is what turns a
|
|
794
|
+
// quadratic scan into a linear one.
|
|
795
|
+
const failedFrom = ctx.noCloser.get(char);
|
|
796
|
+
if (failedFrom !== undefined && at >= failedFrom)
|
|
797
|
+
return null;
|
|
798
|
+
for (let i = at + run; i < source.length; i++) {
|
|
799
|
+
const current = source[i];
|
|
800
|
+
if (current === '\\') {
|
|
801
|
+
i++;
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
if (current === '`') {
|
|
805
|
+
const backticks = /^`+/.exec(source.slice(i))[0];
|
|
806
|
+
const end = findCodeClose(source, i + backticks.length, backticks);
|
|
807
|
+
if (end !== -1) {
|
|
808
|
+
i = end + backticks.length - 1;
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (current !== char)
|
|
813
|
+
continue;
|
|
814
|
+
let end = i;
|
|
815
|
+
while (source[end] === char)
|
|
816
|
+
end++;
|
|
817
|
+
const length = end - i;
|
|
818
|
+
if (length < use || /\s/.test(source[i - 1])) {
|
|
819
|
+
i = end - 1;
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (char === '_' && end < source.length && /[\p{L}\p{N}]/u.test(source[end])) {
|
|
823
|
+
i = end - 1;
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
// Take `use` markers from the START of the opener and the END of the closer,
|
|
827
|
+
// so `***x***` leaves `*x*` inside the strong node and nests correctly.
|
|
828
|
+
const children = inlines(source.slice(at + use, end - use), depth + 1, ctx);
|
|
829
|
+
const kind = char === '~' ? 'del' : use === 2 ? 'strong' : 'em';
|
|
830
|
+
return { node: { kind, children }, end };
|
|
831
|
+
}
|
|
832
|
+
// No closer anywhere in this source. Recorded so the next opener of the same
|
|
833
|
+
// character does not repeat the walk.
|
|
834
|
+
//
|
|
835
|
+
// Note what does NOT happen here any more: an optimistic close. Guessing at the
|
|
836
|
+
// scanner level meant a marker 40 characters back swallowed the rest of the
|
|
837
|
+
// block — including markup that had already parsed, so an unclosed backtick ate
|
|
838
|
+
// a finished `[docs](url)` and rendered it as monospaced source. Mid-stream
|
|
839
|
+
// guessing now happens once, on the document's tail, where it can be bounded.
|
|
840
|
+
// See {@link applyStreamingTail}.
|
|
841
|
+
ctx.noCloser.set(char, Math.min(failedFrom ?? Number.POSITIVE_INFINITY, at));
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
/** Text content of an inline tree — for `alt` and for heading slugs. */
|
|
845
|
+
function plainText(inlines) {
|
|
846
|
+
return inlines
|
|
847
|
+
.map(node => {
|
|
848
|
+
switch (node.kind) {
|
|
849
|
+
case 'text':
|
|
850
|
+
case 'code':
|
|
851
|
+
return node.value;
|
|
852
|
+
case 'image':
|
|
853
|
+
return node.alt;
|
|
854
|
+
case 'break':
|
|
855
|
+
return ' ';
|
|
856
|
+
default:
|
|
857
|
+
return plainText(node.children);
|
|
858
|
+
}
|
|
859
|
+
})
|
|
860
|
+
.join('');
|
|
861
|
+
}
|
|
862
|
+
// ---------------------------------------------------------------------------
|
|
863
|
+
// Streaming
|
|
864
|
+
// ---------------------------------------------------------------------------
|
|
865
|
+
/**
|
|
866
|
+
* How far back from the end of the document a marker may sit and still be treated
|
|
867
|
+
* as mid-flight.
|
|
868
|
+
*
|
|
869
|
+
* The optimistic close is a GUESS, and a guess needs a blast radius. Unbounded, a
|
|
870
|
+
* `*` forty characters back turned the rest of a finished sentence into emphasis
|
|
871
|
+
* for the whole stream and then unwrapped it at the end — the exact restyle the
|
|
872
|
+
* feature exists to avoid. Within a chunk's distance of the end, an unmatched
|
|
873
|
+
* marker is nearly always a construct still arriving.
|
|
874
|
+
*/
|
|
875
|
+
const TAIL_WINDOW = 80;
|
|
876
|
+
/**
|
|
877
|
+
* A run that is worth closing optimistically, and its node kind.
|
|
878
|
+
*
|
|
879
|
+
* Deliberately no single `*` or `_`. Those are multiplication signs, file names
|
|
880
|
+
* and `snake_case` far more often than they are emphasis mid-word, and getting it
|
|
881
|
+
* wrong there restyles ordinary prose. `**`, `__`, `~~` and a backtick are
|
|
882
|
+
* unambiguous enough to bet on.
|
|
883
|
+
*/
|
|
884
|
+
const OPTIMISTIC_RE = /(\*\*|__|~~|`)([^\s*_~`][^*_~`]*)$/;
|
|
885
|
+
/**
|
|
886
|
+
* A construct the stream has only begun to type: a trailing marker run, or a link
|
|
887
|
+
* whose destination has not arrived.
|
|
888
|
+
*
|
|
889
|
+
* Anchored, and every alternative bounded. The version this replaced allowed
|
|
890
|
+
* `\][^)]*` to run to the end of the text, so a CLOSED `[docs]` in the middle of a
|
|
891
|
+
* sentence matched from the bracket onwards and deleted the rest of the paragraph
|
|
892
|
+
* — and kept deleting it for the whole stream, because the paragraph stayed the
|
|
893
|
+
* last block. `See the [docs] for details.` rendered as `See the `.
|
|
894
|
+
*/
|
|
895
|
+
const FRAGMENT_RE = /(?:[*_~`]+|!?\[[^\]\n]*(?:\]\([^)\s]*)?)$/;
|
|
896
|
+
/**
|
|
897
|
+
* The mid-stream adjustments, applied once to the document's TAIL.
|
|
898
|
+
*
|
|
899
|
+
* Everything the streaming mode does beyond parsing an open fence lives here, and
|
|
900
|
+
* the reason is scope. The tail is the only part of a streamed document that is
|
|
901
|
+
* still changing, so it is the only part where guessing is defensible; a guess
|
|
902
|
+
* made inside the inline scanner cannot tell whether it is at the end of the
|
|
903
|
+
* document or in the middle of a paragraph that finished two chunks ago.
|
|
904
|
+
*
|
|
905
|
+
* It descends: the last block may be a quote, or the last item of a list, which is
|
|
906
|
+
* where most streamed prose actually lives. The version that only looked at a
|
|
907
|
+
* top-level paragraph left the `**` flicker in place everywhere else while the
|
|
908
|
+
* documentation claimed otherwise.
|
|
909
|
+
*/
|
|
910
|
+
function applyStreamingTail(blocks) {
|
|
911
|
+
const last = blocks[blocks.length - 1];
|
|
912
|
+
if (!last)
|
|
913
|
+
return blocks;
|
|
914
|
+
const patched = patchBlock(last);
|
|
915
|
+
if (patched === last)
|
|
916
|
+
return blocks;
|
|
917
|
+
const head = blocks.slice(0, -1);
|
|
918
|
+
return patched === null ? head : [...head, patched];
|
|
919
|
+
}
|
|
920
|
+
/** `null` to drop the block entirely, the same reference to leave it alone. */
|
|
921
|
+
function patchBlock(block) {
|
|
922
|
+
switch (block.kind) {
|
|
923
|
+
case 'paragraph':
|
|
924
|
+
case 'heading': {
|
|
925
|
+
// `## ` with nothing after it yet: a node that would render as an empty box.
|
|
926
|
+
if (block.inlines.length === 0)
|
|
927
|
+
return null;
|
|
928
|
+
const inlines = patchInlines(block.inlines);
|
|
929
|
+
if (inlines === block.inlines)
|
|
930
|
+
return block;
|
|
931
|
+
return inlines.length === 0 ? null : { ...block, inlines };
|
|
932
|
+
}
|
|
933
|
+
case 'quote': {
|
|
934
|
+
const children = applyStreamingTail(block.children);
|
|
935
|
+
if (children === block.children)
|
|
936
|
+
return block;
|
|
937
|
+
return children.length === 0 ? null : { ...block, children };
|
|
938
|
+
}
|
|
939
|
+
case 'list': {
|
|
940
|
+
const item = block.items[block.items.length - 1];
|
|
941
|
+
if (!item)
|
|
942
|
+
return block;
|
|
943
|
+
const children = applyStreamingTail(item.children);
|
|
944
|
+
if (children === item.children)
|
|
945
|
+
return block;
|
|
946
|
+
// The item stays even when its content goes: the bullet is already on
|
|
947
|
+
// screen, and removing it would be a flicker of its own.
|
|
948
|
+
return { ...block, items: [...block.items.slice(0, -1), { ...item, children }] };
|
|
949
|
+
}
|
|
950
|
+
default:
|
|
951
|
+
return block;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
function patchInlines(nodes) {
|
|
955
|
+
const last = nodes[nodes.length - 1];
|
|
956
|
+
if (!last)
|
|
957
|
+
return nodes;
|
|
958
|
+
// The tail may be inside a wrapper — `**bold and [lin` puts it inside the strong.
|
|
959
|
+
if (last.kind === 'strong' || last.kind === 'em' || last.kind === 'del' || last.kind === 'link') {
|
|
960
|
+
const children = patchInlines(last.children);
|
|
961
|
+
if (children === last.children)
|
|
962
|
+
return nodes;
|
|
963
|
+
const head = nodes.slice(0, -1);
|
|
964
|
+
return children.length === 0 ? head : [...head, { ...last, children }];
|
|
965
|
+
}
|
|
966
|
+
// A backtick that has only just arrived, already painted as an empty chip.
|
|
967
|
+
if (last.kind === 'code' && last.value === '')
|
|
968
|
+
return nodes.slice(0, -1);
|
|
969
|
+
if (last.kind !== 'text')
|
|
970
|
+
return nodes;
|
|
971
|
+
const patched = patchText(last.value);
|
|
972
|
+
return patched === null ? nodes : [...nodes.slice(0, -1), ...patched];
|
|
973
|
+
}
|
|
974
|
+
/** `null` when the text is not a tail that needs adjusting. */
|
|
975
|
+
function patchText(value) {
|
|
976
|
+
let text = value;
|
|
977
|
+
// Repeatedly, because dropping `[lin` from `**bold and [lin` exposes the `**`
|
|
978
|
+
// behind it, which is then a candidate for the optimistic close below.
|
|
979
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
980
|
+
const shorter = text.replace(FRAGMENT_RE, '');
|
|
981
|
+
if (shorter === text)
|
|
982
|
+
break;
|
|
983
|
+
text = shorter;
|
|
984
|
+
}
|
|
985
|
+
const opening = OPTIMISTIC_RE.exec(text);
|
|
986
|
+
if (opening) {
|
|
987
|
+
const [matched, marker, content] = opening;
|
|
988
|
+
const at = text.length - matched.length;
|
|
989
|
+
const before = text.slice(0, at);
|
|
990
|
+
// `_` may not close against a word character, so mid-word it is not a marker
|
|
991
|
+
// at all — `snake_case` must not italicise as it arrives.
|
|
992
|
+
const wordBefore = marker === '__' && at > 0 && /[\p{L}\p{N}]/u.test(text[at - 1]);
|
|
993
|
+
if (!wordBefore && content.length <= TAIL_WINDOW) {
|
|
994
|
+
const node = marker === '`'
|
|
995
|
+
? { kind: 'code', value: content }
|
|
996
|
+
: { kind: marker === '~~' ? 'del' : 'strong', children: [{ kind: 'text', value: content }] };
|
|
997
|
+
return before ? [{ kind: 'text', value: before }, node] : [node];
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (text === value)
|
|
1001
|
+
return null;
|
|
1002
|
+
return text ? [{ kind: 'text', value: text }] : [];
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* @license
|
|
1007
|
+
*
|
|
1008
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
1009
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
1010
|
+
*/
|
|
1011
|
+
/**
|
|
1012
|
+
* The app's syntax highlighter, or `null` for none.
|
|
1013
|
+
*
|
|
1014
|
+
* Defaults to `null` on purpose: highlighting means a grammar engine, and one of
|
|
1015
|
+
* those in the library's dependency tree would be paid for by every consumer who
|
|
1016
|
+
* renders a paragraph of text. Provide it with `provideWrMarkdownHighlighter()`.
|
|
1017
|
+
*/
|
|
1018
|
+
const WR_MARKDOWN_HIGHLIGHTER = new InjectionToken('WR_MARKDOWN_HIGHLIGHTER', {
|
|
1019
|
+
factory: () => null,
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* @license
|
|
1024
|
+
*
|
|
1025
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
1026
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
1027
|
+
*/
|
|
1028
|
+
/**
|
|
1029
|
+
* How many highlighted code blocks to remember, per language.
|
|
1030
|
+
*
|
|
1031
|
+
* A requirement of streaming rather than an optimisation: a code block that grows
|
|
1032
|
+
* by a token per chunk asks to be highlighted with a DIFFERENT string every time,
|
|
1033
|
+
* so an unbounded store would hold every intermediate state of every block for the
|
|
1034
|
+
* life of the app — and this service is `providedIn: 'root'`.
|
|
1035
|
+
*
|
|
1036
|
+
* Comfortably larger than any one document, deliberately. A window too small to
|
|
1037
|
+
* hold what is on screen means every pass evicts entries that same pass still
|
|
1038
|
+
* needs and asks for them again — a grammar engine running for ever on a page that
|
|
1039
|
+
* has stopped changing.
|
|
1040
|
+
*/
|
|
1041
|
+
const CACHE_LIMIT = 256;
|
|
1042
|
+
/**
|
|
1043
|
+
* Turns fenced code into coloured spans, once per distinct block.
|
|
1044
|
+
*
|
|
1045
|
+
* **Two rules hold this together, and both were learned by breaking them.**
|
|
1046
|
+
*
|
|
1047
|
+
* `request` never READS a signal that it writes. An earlier version kept the
|
|
1048
|
+
* answers in a signal and rebuilt it on every store, so the component's `effect` —
|
|
1049
|
+
* which calls `request` — depended on the signal `request` wrote, and a document
|
|
1050
|
+
* with more code blocks than the cache could hold looped for ever: evict,
|
|
1051
|
+
* re-request, write, re-dirty. It did not merely spin, it starved the timers that
|
|
1052
|
+
* were supposed to fail the test. The plain `Map` is the source of truth now, and a
|
|
1053
|
+
* single `revision` counter is the only reactive part; nothing that writes reads it
|
|
1054
|
+
* back.
|
|
1055
|
+
*
|
|
1056
|
+
* And `linesFor` is a pure read, safe to call while Angular renders. Asking a
|
|
1057
|
+
* highlighter for a block means writing when the answer lands, and Angular refuses
|
|
1058
|
+
* a signal write during rendering (NG0600) — so the component requests from an
|
|
1059
|
+
* `effect`, which runs after the render, and the template only reads.
|
|
1060
|
+
*
|
|
1061
|
+
* Blocks appear as plain text first and gain colour when the answer arrives. For an
|
|
1062
|
+
* async highlighter that is right rather than a compromise: a grammar engine loads
|
|
1063
|
+
* WASM, and blocking on it would leave the reader with an empty box instead of
|
|
1064
|
+
* their code.
|
|
1065
|
+
*/
|
|
1066
|
+
class WrMarkdownHighlight {
|
|
1067
|
+
highlighter = inject(WR_MARKDOWN_HIGHLIGHTER);
|
|
1068
|
+
/** Language to code to answer. Insertion order is least-recently-rendered first. */
|
|
1069
|
+
cache = new Map();
|
|
1070
|
+
/** Bumped whenever an answer lands. Read by the template, never by a writer. */
|
|
1071
|
+
revision = signal(0, /* @ts-ignore */
|
|
1072
|
+
...(ngDevMode ? [{ debugName: "revision" }] : /* istanbul ignore next */ []));
|
|
1073
|
+
generationSignal = signal(0, /* @ts-ignore */
|
|
1074
|
+
...(ngDevMode ? [{ debugName: "generationSignal" }] : /* istanbul ignore next */ []));
|
|
1075
|
+
/**
|
|
1076
|
+
* Bumped ONLY by {@link invalidate}. Read it in the effect that requests, so a
|
|
1077
|
+
* consumer can force a re-ask.
|
|
1078
|
+
*
|
|
1079
|
+
* Separate from `revision` on purpose, and the separation is the whole safety
|
|
1080
|
+
* argument. An effect that requests and also depends on the signal that storing
|
|
1081
|
+
* an answer writes is the shape that looped for ever here once: past the cache
|
|
1082
|
+
* limit, requesting stops being idempotent, so each pass writes, re-dirties the
|
|
1083
|
+
* effect, evicts and requests again. Nothing inside `request` touches this one.
|
|
1084
|
+
*/
|
|
1085
|
+
generation = this.generationSignal.asReadonly();
|
|
1086
|
+
/** Spans for one block, or `null` for plain text. Safe to call from a template. */
|
|
1087
|
+
linesFor(code, language) {
|
|
1088
|
+
if (!language)
|
|
1089
|
+
return null;
|
|
1090
|
+
// Establishes the dependency, so a late answer re-renders the block waiting.
|
|
1091
|
+
this.revision();
|
|
1092
|
+
const perLanguage = this.cache.get(language);
|
|
1093
|
+
const entry = perLanguage?.get(code);
|
|
1094
|
+
if (!entry || !perLanguage)
|
|
1095
|
+
return null;
|
|
1096
|
+
// Touched on read, so eviction drops what nobody is rendering rather than what
|
|
1097
|
+
// arrived first. Insertion order had it exactly backwards: mid-stream it
|
|
1098
|
+
// discarded the finished block the reader was looking at and kept the
|
|
1099
|
+
// half-typed intermediates, and the finished block flashed back to plain text.
|
|
1100
|
+
perLanguage.delete(code);
|
|
1101
|
+
perLanguage.set(code, entry);
|
|
1102
|
+
return entry.lines;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Forget every answer and ask again on the next render.
|
|
1106
|
+
*
|
|
1107
|
+
* The cache is keyed on `(language, code)` and nothing else, which is right for
|
|
1108
|
+
* the streaming case it exists for and wrong for one real situation: a
|
|
1109
|
+
* highlighter that returns per-theme colours serves whichever palette was
|
|
1110
|
+
* current when a block was first rendered, for ever. There is no theme
|
|
1111
|
+
* dimension to key on — the library cannot know what a highlighter's answer
|
|
1112
|
+
* depends on — so the honest fix is to let the consumer say when it changed.
|
|
1113
|
+
*
|
|
1114
|
+
* A highlighter that returns theme-agnostic colours (CSS `light-dark()`, a
|
|
1115
|
+
* custom property) needs none of this.
|
|
1116
|
+
*
|
|
1117
|
+
* @example
|
|
1118
|
+
* ```ts
|
|
1119
|
+
* const highlight = inject(WrMarkdownHighlight);
|
|
1120
|
+
* effect(() => {
|
|
1121
|
+
* theme.current();
|
|
1122
|
+
* highlight.invalidate();
|
|
1123
|
+
* });
|
|
1124
|
+
* ```
|
|
1125
|
+
*/
|
|
1126
|
+
invalidate() {
|
|
1127
|
+
this.cache.clear();
|
|
1128
|
+
this.generationSignal.update(generation => generation + 1);
|
|
1129
|
+
this.revision.update(revision => revision + 1);
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Ask for a block to be highlighted. Idempotent, and safe to call for every
|
|
1133
|
+
* block on every change — call it from an `effect`, never from a template.
|
|
1134
|
+
*/
|
|
1135
|
+
request(code, language) {
|
|
1136
|
+
// A bare fence has no grammar to pick, so there is nothing to ask for.
|
|
1137
|
+
if (!this.highlighter || !language || !code)
|
|
1138
|
+
return;
|
|
1139
|
+
const perLanguage = this.cache.get(language) ?? new Map();
|
|
1140
|
+
this.cache.set(language, perLanguage);
|
|
1141
|
+
// Presence IS the record of having asked — a separate `asked` set was a second
|
|
1142
|
+
// structure to keep in step with this one, and eviction only pruned one of them.
|
|
1143
|
+
if (perLanguage.has(code))
|
|
1144
|
+
return;
|
|
1145
|
+
const entry = { lines: null };
|
|
1146
|
+
perLanguage.set(code, entry);
|
|
1147
|
+
this.evict(perLanguage);
|
|
1148
|
+
let result;
|
|
1149
|
+
try {
|
|
1150
|
+
result = this.highlighter(code, language);
|
|
1151
|
+
}
|
|
1152
|
+
catch {
|
|
1153
|
+
// A highlighter that throws on an unknown grammar costs the reader colour,
|
|
1154
|
+
// not their code.
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
if (result instanceof Promise) {
|
|
1158
|
+
result.then(lines => this.fill(entry, lines)).catch(() => undefined);
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
this.fill(entry, result);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Fill in an answer and let the view know.
|
|
1166
|
+
*
|
|
1167
|
+
* Writes through the entry OBJECT rather than looking its key up again. The entry
|
|
1168
|
+
* may have been evicted while the promise was in flight, in which case this
|
|
1169
|
+
* writes to something nothing holds — which is the intended outcome. The version
|
|
1170
|
+
* that re-inserted by key put entries back behind eviction's back, and the cache
|
|
1171
|
+
* grew without a ceiling for exactly the async highlighter its limit exists for.
|
|
1172
|
+
*/
|
|
1173
|
+
fill(entry, lines) {
|
|
1174
|
+
if (!lines)
|
|
1175
|
+
return;
|
|
1176
|
+
entry.lines = lines;
|
|
1177
|
+
this.revision.update(revision => revision + 1);
|
|
1178
|
+
}
|
|
1179
|
+
/** `Map` iterates in insertion order, and `linesFor` re-inserts on read. */
|
|
1180
|
+
evict(perLanguage) {
|
|
1181
|
+
while (perLanguage.size > CACHE_LIMIT) {
|
|
1182
|
+
const oldest = perLanguage.keys().next();
|
|
1183
|
+
if (oldest.done)
|
|
1184
|
+
return;
|
|
1185
|
+
perLanguage.delete(oldest.value);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: WrMarkdownHighlight, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
|
|
1189
|
+
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: WrMarkdownHighlight, providedIn: 'root' });
|
|
1190
|
+
}
|
|
1191
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: WrMarkdownHighlight, decorators: [{
|
|
1192
|
+
type: Injectable,
|
|
1193
|
+
args: [{ providedIn: 'root' }]
|
|
1194
|
+
}] });
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* @license
|
|
1198
|
+
*
|
|
1199
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
1200
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
1201
|
+
*/
|
|
1202
|
+
/** How long the copy button stays in its "copied" state. */
|
|
1203
|
+
const COPIED_FEEDBACK_MS = 2000;
|
|
1204
|
+
/** Code blocks anywhere in the tree — a quote or a list item holds blocks too. */
|
|
1205
|
+
function collectCode(blocks) {
|
|
1206
|
+
const found = [];
|
|
1207
|
+
for (const block of blocks) {
|
|
1208
|
+
if (block.kind === 'code')
|
|
1209
|
+
found.push({ code: block.code, language: block.language });
|
|
1210
|
+
if (block.kind === 'quote')
|
|
1211
|
+
found.push(...collectCode(block.children));
|
|
1212
|
+
if (block.kind === 'list')
|
|
1213
|
+
for (const item of block.items)
|
|
1214
|
+
found.push(...collectCode(item.children));
|
|
1215
|
+
}
|
|
1216
|
+
return found;
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* Renders markdown — as DOM, not as HTML.
|
|
1220
|
+
*
|
|
1221
|
+
* Built for streamed model output, and useful for any markdown an app does not
|
|
1222
|
+
* write itself: comments, release notes, a README. `[streaming]` makes a partial
|
|
1223
|
+
* document render like a whole one (see {@link parseMarkdown}); without it the
|
|
1224
|
+
* component is an ordinary renderer.
|
|
1225
|
+
*
|
|
1226
|
+
* **Raw HTML in the source is escaped, never rendered.** That is a deliberate
|
|
1227
|
+
* limit rather than an unfinished feature: the input is untrusted by
|
|
1228
|
+
* construction, `[innerHTML]` is how every comparable renderer takes it, and one
|
|
1229
|
+
* `<img onerror>` is the whole cost of being wrong. Nothing here is parsed as
|
|
1230
|
+
* HTML at any point — every text node is a text node and every `href` goes
|
|
1231
|
+
* through Angular's URL sanitizer on top of this component's own scheme check.
|
|
1232
|
+
*
|
|
1233
|
+
* @example
|
|
1234
|
+
* ```html
|
|
1235
|
+
* <wr-markdown [value]="answer()" [streaming]="pending()" copyable />
|
|
1236
|
+
* ```
|
|
1237
|
+
*
|
|
1238
|
+
* @see https://ngwr.dev/reference/components/markdown
|
|
1239
|
+
*/
|
|
1240
|
+
class WrMarkdown {
|
|
1241
|
+
highlight = inject(WrMarkdownHighlight);
|
|
1242
|
+
destroyRef = inject(DestroyRef);
|
|
1243
|
+
/** The markdown source. */
|
|
1244
|
+
value = input('', /* @ts-ignore */
|
|
1245
|
+
...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
|
|
1246
|
+
/**
|
|
1247
|
+
* The source is a prefix of a longer document.
|
|
1248
|
+
*
|
|
1249
|
+
* Turns on partial-safe parsing — an open fence renders as code, a half-typed
|
|
1250
|
+
* `**bold` renders as bold rather than as asterisks — and marks the host with
|
|
1251
|
+
* `.wr-markdown--streaming`, which paints a caret after the last block.
|
|
1252
|
+
*
|
|
1253
|
+
* @default false
|
|
1254
|
+
*/
|
|
1255
|
+
streaming = input(false, { ...(ngDevMode ? { debugName: "streaming" } : /* istanbul ignore next */ {}), transform: coerceBooleanProperty });
|
|
1256
|
+
/**
|
|
1257
|
+
* Show a copy button on finished code blocks.
|
|
1258
|
+
*
|
|
1259
|
+
* Falls back to `markdown.copyable` from `provideWrConfig()`, then `false`.
|
|
1260
|
+
* Only CLOSED blocks get one: mid-stream the code is not all there, and a
|
|
1261
|
+
* button that copies half a snippet is worse than no button.
|
|
1262
|
+
*/
|
|
1263
|
+
copyable = input(null, { ...(ngDevMode ? { debugName: "copyable" } : /* istanbul ignore next */ {}),
|
|
1264
|
+
// `null` survives, because it is what "the template said nothing" means to
|
|
1265
|
+
// `useConfigValue` — but a bare `copyable` attribute has to reach `true`, and
|
|
1266
|
+
// without a transform it arrives as `''` and does not compile under
|
|
1267
|
+
// `strictTemplates`. The component's own JSDoc example was the proof.
|
|
1268
|
+
transform: value => (value == null ? null : coerceBooleanProperty(value)) });
|
|
1269
|
+
/**
|
|
1270
|
+
* Where links open. `_blank` also sets `rel="noopener noreferrer"`, which is
|
|
1271
|
+
* not optional — a target without it hands the opener to a page whose URL came
|
|
1272
|
+
* from the document being rendered.
|
|
1273
|
+
*
|
|
1274
|
+
* Falls back to `markdown.linkTarget` from `provideWrConfig()`, then no target.
|
|
1275
|
+
*/
|
|
1276
|
+
linkTarget = input(null, /* @ts-ignore */
|
|
1277
|
+
...(ngDevMode ? [{ debugName: "linkTarget" }] : /* istanbul ignore next */ []));
|
|
1278
|
+
/**
|
|
1279
|
+
* Put a slugged `id` on every heading, so the document can be linked into.
|
|
1280
|
+
*
|
|
1281
|
+
* @default true
|
|
1282
|
+
*/
|
|
1283
|
+
headingIds = input(true, { ...(ngDevMode ? { debugName: "headingIds" } : /* istanbul ignore next */ {}), transform: coerceBooleanProperty });
|
|
1284
|
+
/**
|
|
1285
|
+
* Namespace for the generated heading ids, and for the in-document `#fragment`
|
|
1286
|
+
* links that point at them.
|
|
1287
|
+
*
|
|
1288
|
+
* Defaults to `'user-content-'`, which is what GitHub emits for untrusted
|
|
1289
|
+
* markdown, and for the same reason: without a namespace a document that
|
|
1290
|
+
* happens to contain `# Search` renders `id="search"` and steals it from the
|
|
1291
|
+
* page around it. That is not cosmetic — a `<label for="search">` silently
|
|
1292
|
+
* stops labelling its input, and `getElementById` starts answering with a
|
|
1293
|
+
* heading. Anchors written INSIDE the document keep working, because a bare
|
|
1294
|
+
* `#fragment` href gets the same prefix.
|
|
1295
|
+
*
|
|
1296
|
+
* Set it to `''` for a document you author yourself and want clean anchors for.
|
|
1297
|
+
*/
|
|
1298
|
+
headingIdPrefix = input('user-content-', /* @ts-ignore */
|
|
1299
|
+
...(ngDevMode ? [{ debugName: "headingIdPrefix" }] : /* istanbul ignore next */ []));
|
|
1300
|
+
/** Accessible name of the copy button. Falls back to `markdown.copy`, then `'Copy code'`. */
|
|
1301
|
+
copyLabel = input(null, /* @ts-ignore */
|
|
1302
|
+
...(ngDevMode ? [{ debugName: "copyLabel" }] : /* istanbul ignore next */ []));
|
|
1303
|
+
/** Announced after a successful copy. Falls back to `markdown.copied`, then `'Copied'`. */
|
|
1304
|
+
copiedLabel = input(null, /* @ts-ignore */
|
|
1305
|
+
...(ngDevMode ? [{ debugName: "copiedLabel" }] : /* istanbul ignore next */ []));
|
|
1306
|
+
/** Read out for a checked task item. Falls back to `markdown.taskDone`, then `'Done:'`. */
|
|
1307
|
+
taskDoneLabel = input(null, /* @ts-ignore */
|
|
1308
|
+
...(ngDevMode ? [{ debugName: "taskDoneLabel" }] : /* istanbul ignore next */ []));
|
|
1309
|
+
/** Read out for an unchecked task item. Falls back to `markdown.taskTodo`, then `'To do:'`. */
|
|
1310
|
+
taskTodoLabel = input(null, /* @ts-ignore */
|
|
1311
|
+
...(ngDevMode ? [{ debugName: "taskTodoLabel" }] : /* istanbul ignore next */ []));
|
|
1312
|
+
resolvedCopyable = useConfigValue(this.copyable, config => config.markdown?.copyable, false);
|
|
1313
|
+
resolvedLinkTarget = useConfigValue(this.linkTarget, config => config.markdown?.linkTarget, null);
|
|
1314
|
+
resolvedCopyLabel = useI18nText(this.copyLabel, 'markdown.copy', 'Copy code');
|
|
1315
|
+
resolvedCopiedLabel = useI18nText(this.copiedLabel, 'markdown.copied', 'Copied');
|
|
1316
|
+
resolvedTaskDoneLabel = useI18nText(this.taskDoneLabel, 'markdown.taskDone', 'Done:');
|
|
1317
|
+
resolvedTaskTodoLabel = useI18nText(this.taskTodoLabel, 'markdown.taskTodo', 'To do:');
|
|
1318
|
+
/**
|
|
1319
|
+
* The parsed document, re-derived whenever the source changes.
|
|
1320
|
+
*
|
|
1321
|
+
* The WHOLE buffer, on every chunk — so a streamed answer costs O(n²) over the
|
|
1322
|
+
* stream. That is a deliberate trade rather than an oversight: freezing the
|
|
1323
|
+
* blocks before the last blank line would halve it, but a blank line is not a
|
|
1324
|
+
* safe boundary in markdown (a list continues across one), and a wrong boundary
|
|
1325
|
+
* corrupts the document rather than slowing it down. The constant is small —
|
|
1326
|
+
* 300 KB of prose parses in ~120 ms — and `@for`'s `track $index` keeps the
|
|
1327
|
+
* DOM cost proportional to what actually changed.
|
|
1328
|
+
*/
|
|
1329
|
+
blocks = computed(() => parseMarkdown(this.value(), { streaming: this.streaming() }), /* @ts-ignore */
|
|
1330
|
+
...(ngDevMode ? [{ debugName: "blocks" }] : /* istanbul ignore next */ []));
|
|
1331
|
+
/** `rel` is bound, not hard-coded, so a same-tab link does not carry a pointless one. */
|
|
1332
|
+
linkRel = computed(() => (this.resolvedLinkTarget() === '_blank' ? 'noopener noreferrer' : null), /* @ts-ignore */
|
|
1333
|
+
...(ngDevMode ? [{ debugName: "linkRel" }] : /* istanbul ignore next */ []));
|
|
1334
|
+
/**
|
|
1335
|
+
* Which code block last got copied, held by IDENTITY.
|
|
1336
|
+
*
|
|
1337
|
+
* Keyed on the code string, two blocks containing the same snippet both lit up
|
|
1338
|
+
* — and a document that shows the same one-liner twice is ordinary. Block nodes
|
|
1339
|
+
* are recreated whenever the source changes, so the confirmation also clears
|
|
1340
|
+
* itself on the next chunk, which is the right lifetime for it.
|
|
1341
|
+
*/
|
|
1342
|
+
copiedBlock = signal(null, /* @ts-ignore */
|
|
1343
|
+
...(ngDevMode ? [{ debugName: "copiedBlock" }] : /* istanbul ignore next */ []));
|
|
1344
|
+
copiedTimer = null;
|
|
1345
|
+
constructor() {
|
|
1346
|
+
// Requested here rather than from the template: asking the highlighter means
|
|
1347
|
+
// writing a signal when the answer lands, and Angular refuses a signal write
|
|
1348
|
+
// during rendering (NG0600). An effect runs after it.
|
|
1349
|
+
effect(() => {
|
|
1350
|
+
// `generation` is what makes `WrMarkdownHighlight.invalidate()` reach the
|
|
1351
|
+
// blocks already on screen. It is deliberately NOT the signal that storing
|
|
1352
|
+
// an answer bumps — depending on that one from here is the loop.
|
|
1353
|
+
this.highlight.generation();
|
|
1354
|
+
for (const block of this.codeBlocks())
|
|
1355
|
+
this.highlight.request(block.code, block.language);
|
|
1356
|
+
});
|
|
1357
|
+
this.destroyRef.onDestroy(() => {
|
|
1358
|
+
if (this.copiedTimer !== null)
|
|
1359
|
+
clearTimeout(this.copiedTimer);
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* A newline as data rather than as template text.
|
|
1364
|
+
*
|
|
1365
|
+
* The lines of a highlighted block are separate elements, and what separates
|
|
1366
|
+
* them on screen — and in a copy — is a real `\n` inside the `<pre>`. Angular
|
|
1367
|
+
* strips whitespace-only text nodes from templates, so the character cannot be
|
|
1368
|
+
* typed there; an interpolation of it survives.
|
|
1369
|
+
*/
|
|
1370
|
+
newline = String.fromCharCode(10);
|
|
1371
|
+
/** Every code block in the document, nested ones included. */
|
|
1372
|
+
codeBlocks = computed(() => collectCode(this.blocks()), /* @ts-ignore */
|
|
1373
|
+
...(ngDevMode ? [{ debugName: "codeBlocks" }] : /* istanbul ignore next */ []));
|
|
1374
|
+
/** Spans for a code block, or `null` to render it as plain text. */
|
|
1375
|
+
linesOf(code, language) {
|
|
1376
|
+
return this.highlight.linesFor(code, language);
|
|
1377
|
+
}
|
|
1378
|
+
isCopied(block) {
|
|
1379
|
+
return this.copiedBlock() === block;
|
|
1380
|
+
}
|
|
1381
|
+
onCopied(block) {
|
|
1382
|
+
this.copiedBlock.set(block);
|
|
1383
|
+
if (this.copiedTimer !== null)
|
|
1384
|
+
clearTimeout(this.copiedTimer);
|
|
1385
|
+
this.copiedTimer = setTimeout(() => {
|
|
1386
|
+
this.copiedBlock.set(null);
|
|
1387
|
+
this.copiedTimer = null;
|
|
1388
|
+
}, COPIED_FEEDBACK_MS);
|
|
1389
|
+
}
|
|
1390
|
+
/** A heading's id, namespaced, or `null` when there is nothing to link to. */
|
|
1391
|
+
idOf(id) {
|
|
1392
|
+
if (!this.headingIds() || !id)
|
|
1393
|
+
return null;
|
|
1394
|
+
return `${this.headingIdPrefix()}${id}`;
|
|
1395
|
+
}
|
|
1396
|
+
/**
|
|
1397
|
+
* A link's destination, with a bare fragment pointed at the namespaced id.
|
|
1398
|
+
*
|
|
1399
|
+
* Otherwise prefixing the headings would quietly break every `[see](#section)`
|
|
1400
|
+
* the document writes about itself.
|
|
1401
|
+
*/
|
|
1402
|
+
hrefOf(href) {
|
|
1403
|
+
const prefix = this.headingIdPrefix();
|
|
1404
|
+
if (!prefix || !this.headingIds() || !href.startsWith('#') || href.length < 2)
|
|
1405
|
+
return href;
|
|
1406
|
+
return `#${prefix}${href.slice(1)}`;
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Casts for the recursive templates, and the reason they exist.
|
|
1410
|
+
*
|
|
1411
|
+
* Block content nests — a quote holds blocks, a list item holds blocks — so the
|
|
1412
|
+
* template recurses through `ngTemplateOutlet` into itself. That is the one
|
|
1413
|
+
* recursion shape which adds NO elements to the DOM; a recursive component
|
|
1414
|
+
* would put a host element between `<ul>` and its `<li>`, which is invalid
|
|
1415
|
+
* markup and breaks the list's accessibility tree. The cost is that
|
|
1416
|
+
* `ngTemplateOutlet` context arrives as `any`, and `@switch` cannot narrow
|
|
1417
|
+
* `any`. Casting it back at the top of each template restores full
|
|
1418
|
+
* `strictTemplates` checking inside the loop, where the work happens.
|
|
1419
|
+
*/
|
|
1420
|
+
asBlocks(value) {
|
|
1421
|
+
return value;
|
|
1422
|
+
}
|
|
1423
|
+
asInlines(value) {
|
|
1424
|
+
return value;
|
|
1425
|
+
}
|
|
1426
|
+
asItems(value) {
|
|
1427
|
+
return value;
|
|
1428
|
+
}
|
|
1429
|
+
asCells(value) {
|
|
1430
|
+
return value;
|
|
1431
|
+
}
|
|
1432
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: WrMarkdown, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
1433
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.1", type: WrMarkdown, isStandalone: true, selector: "wr-markdown", inputs: { value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, streaming: { classPropertyName: "streaming", publicName: "streaming", isSignal: true, isRequired: false, transformFunction: null }, copyable: { classPropertyName: "copyable", publicName: "copyable", isSignal: true, isRequired: false, transformFunction: null }, linkTarget: { classPropertyName: "linkTarget", publicName: "linkTarget", isSignal: true, isRequired: false, transformFunction: null }, headingIds: { classPropertyName: "headingIds", publicName: "headingIds", isSignal: true, isRequired: false, transformFunction: null }, headingIdPrefix: { classPropertyName: "headingIdPrefix", publicName: "headingIdPrefix", isSignal: true, isRequired: false, transformFunction: null }, copyLabel: { classPropertyName: "copyLabel", publicName: "copyLabel", isSignal: true, isRequired: false, transformFunction: null }, copiedLabel: { classPropertyName: "copiedLabel", publicName: "copiedLabel", isSignal: true, isRequired: false, transformFunction: null }, taskDoneLabel: { classPropertyName: "taskDoneLabel", publicName: "taskDoneLabel", isSignal: true, isRequired: false, transformFunction: null }, taskTodoLabel: { classPropertyName: "taskTodoLabel", publicName: "taskTodoLabel", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class.wr-markdown--streaming": "streaming()" }, classAttribute: "wr-markdown" }, ngImport: i0, template: "<!--\n Recursion runs through `ngTemplateOutlet` into these templates rather than\n through a child component, so the rendered DOM is exactly the document \u2014 no\n wrapper element between a `<ul>` and its `<li>`. See `asBlocks` for the whole\n reasoning.\n-->\n<ng-template #inlineList let-raw>\n @let nodes = asInlines(raw);\n\n @for (node of nodes; track $index) {\n @switch (node.kind) {\n @case ('text') {\n <!-- Wrapped, and not for tidiness: a bare interpolation on its own line is\n ONE text node that includes the indentation around it, so `a **b** c`\n rendered with a space inside every `<strong>`. Inside an element the\n whitespace becomes separate whitespace-only nodes, which Angular\n removes, and `<ng-container>` itself renders nothing. Prettier\n reformats the bare form back, so this also has to survive `--fix`. -->\n <ng-container>{{ node.value }}</ng-container>\n }\n @case ('code') {\n <!-- `dir=\"ltr\"` because code is not prose: under RTL the browser\n reorders `foo();` to `;foo()`. -->\n <code class=\"wr-markdown__code-inline\" dir=\"ltr\">{{ node.value }}</code>\n }\n @case ('strong') {\n <strong class=\"wr-markdown__strong\"\n ><ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: node.children }\"\n /></strong>\n }\n @case ('em') {\n <em class=\"wr-markdown__em\"\n ><ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: node.children }\"\n /></em>\n }\n @case ('del') {\n <del class=\"wr-markdown__del\"\n ><ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: node.children }\"\n /></del>\n }\n @case ('link') {\n <a\n class=\"wr-markdown__link\"\n [href]=\"hrefOf(node.href)\"\n [attr.title]=\"node.title\"\n [attr.target]=\"resolvedLinkTarget()\"\n [attr.rel]=\"linkRel()\"\n ><ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: node.children }\"\n /></a>\n }\n @case ('image') {\n <img\n class=\"wr-markdown__image\"\n [src]=\"node.src\"\n [alt]=\"node.alt\"\n [attr.title]=\"node.title\"\n loading=\"lazy\"\n decoding=\"async\"\n />\n }\n @case ('break') {\n <br />\n }\n }\n }\n</ng-template>\n\n<ng-template #itemList let-raw let-tight=\"tight\">\n @let items = asItems(raw);\n\n @for (item of items; track $index) {\n <li class=\"wr-markdown__item\" [class.wr-markdown__item--task]=\"item.checked !== null\">\n @if (item.checked !== null) {\n <!-- Presentational, with the state as text beside it: a real\n `<input type=\"checkbox\">` here would be an unlabelled form control,\n which is a serious axe violation, and making it operable would\n promise an interaction a renderer cannot honour. -->\n <span class=\"wr-markdown__task\" [class.wr-markdown__task--checked]=\"item.checked\" aria-hidden=\"true\"></span>\n <span class=\"wr-markdown__sr-only\">{{ item.checked ? resolvedTaskDoneLabel() : resolvedTaskTodoLabel() }}</span>\n }\n <ng-container\n [ngTemplateOutlet]=\"blockList\"\n [ngTemplateOutletContext]=\"{ $implicit: item.children, tight: tight }\"\n />\n </li>\n }\n</ng-template>\n\n<ng-template #blockList let-raw let-tight=\"tight\">\n @let list = asBlocks(raw);\n\n @for (block of list; track $index) {\n @switch (block.kind) {\n @case ('paragraph') {\n <!-- A tight list drops the `<p>`, which is what CommonMark specifies and\n what keeps a one-line list from being double-spaced. -->\n @if (tight) {\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n } @else {\n <p class=\"wr-markdown__paragraph\">\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n </p>\n }\n }\n @case ('heading') {\n @switch (block.level) {\n @case (1) {\n <h1 class=\"wr-markdown__heading\" [attr.id]=\"idOf(block.id)\">\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n </h1>\n }\n @case (2) {\n <h2 class=\"wr-markdown__heading\" [attr.id]=\"idOf(block.id)\">\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n </h2>\n }\n @case (3) {\n <h3 class=\"wr-markdown__heading\" [attr.id]=\"idOf(block.id)\">\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n </h3>\n }\n @case (4) {\n <h4 class=\"wr-markdown__heading\" [attr.id]=\"idOf(block.id)\">\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n </h4>\n }\n @case (5) {\n <h5 class=\"wr-markdown__heading\" [attr.id]=\"idOf(block.id)\">\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n </h5>\n }\n @default {\n <h6 class=\"wr-markdown__heading\" [attr.id]=\"idOf(block.id)\">\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n </h6>\n }\n }\n }\n @case ('code') {\n @let highlighted = linesOf(block.code, block.language);\n\n <div class=\"wr-markdown__code-block\">\n <pre\n class=\"wr-markdown__pre\"\n dir=\"ltr\"\n tabindex=\"0\"\n ><code class=\"wr-markdown__code\" [attr.data-language]=\"block.language\">@if (highlighted) {@for (row of highlighted; track $index) {<span class=\"wr-markdown__line\">@for (span of row; track $index) {<span\n [style.color]=\"span.color\"\n [style.font-style]=\"span.fontStyle === 'italic' ? 'italic' : null\"\n [style.font-weight]=\"span.fontStyle === 'bold' ? 'bold' : null\"\n [style.text-decoration]=\"span.fontStyle === 'underline' ? 'underline' : null\"\n >{{ span.text }}</span>}</span>@if (!$last) {{{ newline }}}}} @else {{{ block.code }}}</code></pre>\n\n @if (resolvedCopyable() && block.closed) {\n <button\n type=\"button\"\n class=\"wr-markdown__copy\"\n [wrCopyToClipboard]=\"block.code\"\n [attr.aria-label]=\"isCopied(block) ? resolvedCopiedLabel() : resolvedCopyLabel()\"\n (copied)=\"onCopied(block)\"\n >\n <svg\n class=\"wr-markdown__copy-icon\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n @if (isCopied(block)) {\n <path d=\"M20 6 9 17l-5-5\" />\n } @else {\n <rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\" />\n <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\" />\n }\n </svg>\n </button>\n }\n </div>\n }\n @case ('quote') {\n <blockquote class=\"wr-markdown__quote\">\n <ng-container\n [ngTemplateOutlet]=\"blockList\"\n [ngTemplateOutletContext]=\"{ $implicit: block.children, tight: false }\"\n />\n </blockquote>\n }\n @case ('list') {\n @if (block.ordered) {\n <ol\n class=\"wr-markdown__list wr-markdown__list--ordered\"\n [attr.start]=\"block.start === 1 ? null : block.start\"\n >\n <ng-container\n [ngTemplateOutlet]=\"itemList\"\n [ngTemplateOutletContext]=\"{ $implicit: block.items, tight: block.tight }\"\n />\n </ol>\n } @else {\n <ul class=\"wr-markdown__list\">\n <ng-container\n [ngTemplateOutlet]=\"itemList\"\n [ngTemplateOutletContext]=\"{ $implicit: block.items, tight: block.tight }\"\n />\n </ul>\n }\n }\n @case ('table') {\n <!-- Its own scroller: a wide table must not push the page sideways, which\n is also what keeps the RTL layout gate quiet about it. -->\n <div class=\"wr-markdown__table-scroll\" tabindex=\"0\">\n <table class=\"wr-markdown__table\">\n <thead>\n <tr>\n @for (cell of block.head; track column; let column = $index) {\n <th class=\"wr-markdown__th\" scope=\"col\" [style.text-align]=\"block.align[column]\">\n <ng-container\n [ngTemplateOutlet]=\"inlineList\"\n [ngTemplateOutletContext]=\"{ $implicit: cell.inlines }\"\n />\n </th>\n }\n </tr>\n </thead>\n <tbody>\n @for (row of block.rows; track $index) {\n <tr>\n @for (cell of asCells(row); track column; let column = $index) {\n <td class=\"wr-markdown__td\" [style.text-align]=\"block.align[column]\">\n <ng-container\n [ngTemplateOutlet]=\"inlineList\"\n [ngTemplateOutletContext]=\"{ $implicit: cell.inlines }\"\n />\n </td>\n }\n </tr>\n }\n </tbody>\n </table>\n </div>\n }\n @case ('rule') {\n <hr class=\"wr-markdown__rule\" />\n }\n }\n }\n</ng-template>\n\n<ng-container [ngTemplateOutlet]=\"blockList\" [ngTemplateOutletContext]=\"{ $implicit: blocks(), tight: false }\" />\n", dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "directive", type: WrCopyToClipboard, selector: "[wrCopyToClipboard]", inputs: ["wrCopyToClipboard"], outputs: ["copied", "copyFailed"] }], encapsulation: i0.ViewEncapsulation.None });
|
|
1434
|
+
}
|
|
1435
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.1", ngImport: i0, type: WrMarkdown, decorators: [{
|
|
1436
|
+
type: Component,
|
|
1437
|
+
args: [{ selector: 'wr-markdown', imports: [NgTemplateOutlet, WrCopyToClipboard], encapsulation: ViewEncapsulation.None, host: {
|
|
1438
|
+
class: 'wr-markdown',
|
|
1439
|
+
'[class.wr-markdown--streaming]': 'streaming()',
|
|
1440
|
+
}, template: "<!--\n Recursion runs through `ngTemplateOutlet` into these templates rather than\n through a child component, so the rendered DOM is exactly the document \u2014 no\n wrapper element between a `<ul>` and its `<li>`. See `asBlocks` for the whole\n reasoning.\n-->\n<ng-template #inlineList let-raw>\n @let nodes = asInlines(raw);\n\n @for (node of nodes; track $index) {\n @switch (node.kind) {\n @case ('text') {\n <!-- Wrapped, and not for tidiness: a bare interpolation on its own line is\n ONE text node that includes the indentation around it, so `a **b** c`\n rendered with a space inside every `<strong>`. Inside an element the\n whitespace becomes separate whitespace-only nodes, which Angular\n removes, and `<ng-container>` itself renders nothing. Prettier\n reformats the bare form back, so this also has to survive `--fix`. -->\n <ng-container>{{ node.value }}</ng-container>\n }\n @case ('code') {\n <!-- `dir=\"ltr\"` because code is not prose: under RTL the browser\n reorders `foo();` to `;foo()`. -->\n <code class=\"wr-markdown__code-inline\" dir=\"ltr\">{{ node.value }}</code>\n }\n @case ('strong') {\n <strong class=\"wr-markdown__strong\"\n ><ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: node.children }\"\n /></strong>\n }\n @case ('em') {\n <em class=\"wr-markdown__em\"\n ><ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: node.children }\"\n /></em>\n }\n @case ('del') {\n <del class=\"wr-markdown__del\"\n ><ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: node.children }\"\n /></del>\n }\n @case ('link') {\n <a\n class=\"wr-markdown__link\"\n [href]=\"hrefOf(node.href)\"\n [attr.title]=\"node.title\"\n [attr.target]=\"resolvedLinkTarget()\"\n [attr.rel]=\"linkRel()\"\n ><ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: node.children }\"\n /></a>\n }\n @case ('image') {\n <img\n class=\"wr-markdown__image\"\n [src]=\"node.src\"\n [alt]=\"node.alt\"\n [attr.title]=\"node.title\"\n loading=\"lazy\"\n decoding=\"async\"\n />\n }\n @case ('break') {\n <br />\n }\n }\n }\n</ng-template>\n\n<ng-template #itemList let-raw let-tight=\"tight\">\n @let items = asItems(raw);\n\n @for (item of items; track $index) {\n <li class=\"wr-markdown__item\" [class.wr-markdown__item--task]=\"item.checked !== null\">\n @if (item.checked !== null) {\n <!-- Presentational, with the state as text beside it: a real\n `<input type=\"checkbox\">` here would be an unlabelled form control,\n which is a serious axe violation, and making it operable would\n promise an interaction a renderer cannot honour. -->\n <span class=\"wr-markdown__task\" [class.wr-markdown__task--checked]=\"item.checked\" aria-hidden=\"true\"></span>\n <span class=\"wr-markdown__sr-only\">{{ item.checked ? resolvedTaskDoneLabel() : resolvedTaskTodoLabel() }}</span>\n }\n <ng-container\n [ngTemplateOutlet]=\"blockList\"\n [ngTemplateOutletContext]=\"{ $implicit: item.children, tight: tight }\"\n />\n </li>\n }\n</ng-template>\n\n<ng-template #blockList let-raw let-tight=\"tight\">\n @let list = asBlocks(raw);\n\n @for (block of list; track $index) {\n @switch (block.kind) {\n @case ('paragraph') {\n <!-- A tight list drops the `<p>`, which is what CommonMark specifies and\n what keeps a one-line list from being double-spaced. -->\n @if (tight) {\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n } @else {\n <p class=\"wr-markdown__paragraph\">\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n </p>\n }\n }\n @case ('heading') {\n @switch (block.level) {\n @case (1) {\n <h1 class=\"wr-markdown__heading\" [attr.id]=\"idOf(block.id)\">\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n </h1>\n }\n @case (2) {\n <h2 class=\"wr-markdown__heading\" [attr.id]=\"idOf(block.id)\">\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n </h2>\n }\n @case (3) {\n <h3 class=\"wr-markdown__heading\" [attr.id]=\"idOf(block.id)\">\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n </h3>\n }\n @case (4) {\n <h4 class=\"wr-markdown__heading\" [attr.id]=\"idOf(block.id)\">\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n </h4>\n }\n @case (5) {\n <h5 class=\"wr-markdown__heading\" [attr.id]=\"idOf(block.id)\">\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n </h5>\n }\n @default {\n <h6 class=\"wr-markdown__heading\" [attr.id]=\"idOf(block.id)\">\n <ng-container [ngTemplateOutlet]=\"inlineList\" [ngTemplateOutletContext]=\"{ $implicit: block.inlines }\" />\n </h6>\n }\n }\n }\n @case ('code') {\n @let highlighted = linesOf(block.code, block.language);\n\n <div class=\"wr-markdown__code-block\">\n <pre\n class=\"wr-markdown__pre\"\n dir=\"ltr\"\n tabindex=\"0\"\n ><code class=\"wr-markdown__code\" [attr.data-language]=\"block.language\">@if (highlighted) {@for (row of highlighted; track $index) {<span class=\"wr-markdown__line\">@for (span of row; track $index) {<span\n [style.color]=\"span.color\"\n [style.font-style]=\"span.fontStyle === 'italic' ? 'italic' : null\"\n [style.font-weight]=\"span.fontStyle === 'bold' ? 'bold' : null\"\n [style.text-decoration]=\"span.fontStyle === 'underline' ? 'underline' : null\"\n >{{ span.text }}</span>}</span>@if (!$last) {{{ newline }}}}} @else {{{ block.code }}}</code></pre>\n\n @if (resolvedCopyable() && block.closed) {\n <button\n type=\"button\"\n class=\"wr-markdown__copy\"\n [wrCopyToClipboard]=\"block.code\"\n [attr.aria-label]=\"isCopied(block) ? resolvedCopiedLabel() : resolvedCopyLabel()\"\n (copied)=\"onCopied(block)\"\n >\n <svg\n class=\"wr-markdown__copy-icon\"\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n aria-hidden=\"true\"\n >\n @if (isCopied(block)) {\n <path d=\"M20 6 9 17l-5-5\" />\n } @else {\n <rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\" />\n <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\" />\n }\n </svg>\n </button>\n }\n </div>\n }\n @case ('quote') {\n <blockquote class=\"wr-markdown__quote\">\n <ng-container\n [ngTemplateOutlet]=\"blockList\"\n [ngTemplateOutletContext]=\"{ $implicit: block.children, tight: false }\"\n />\n </blockquote>\n }\n @case ('list') {\n @if (block.ordered) {\n <ol\n class=\"wr-markdown__list wr-markdown__list--ordered\"\n [attr.start]=\"block.start === 1 ? null : block.start\"\n >\n <ng-container\n [ngTemplateOutlet]=\"itemList\"\n [ngTemplateOutletContext]=\"{ $implicit: block.items, tight: block.tight }\"\n />\n </ol>\n } @else {\n <ul class=\"wr-markdown__list\">\n <ng-container\n [ngTemplateOutlet]=\"itemList\"\n [ngTemplateOutletContext]=\"{ $implicit: block.items, tight: block.tight }\"\n />\n </ul>\n }\n }\n @case ('table') {\n <!-- Its own scroller: a wide table must not push the page sideways, which\n is also what keeps the RTL layout gate quiet about it. -->\n <div class=\"wr-markdown__table-scroll\" tabindex=\"0\">\n <table class=\"wr-markdown__table\">\n <thead>\n <tr>\n @for (cell of block.head; track column; let column = $index) {\n <th class=\"wr-markdown__th\" scope=\"col\" [style.text-align]=\"block.align[column]\">\n <ng-container\n [ngTemplateOutlet]=\"inlineList\"\n [ngTemplateOutletContext]=\"{ $implicit: cell.inlines }\"\n />\n </th>\n }\n </tr>\n </thead>\n <tbody>\n @for (row of block.rows; track $index) {\n <tr>\n @for (cell of asCells(row); track column; let column = $index) {\n <td class=\"wr-markdown__td\" [style.text-align]=\"block.align[column]\">\n <ng-container\n [ngTemplateOutlet]=\"inlineList\"\n [ngTemplateOutletContext]=\"{ $implicit: cell.inlines }\"\n />\n </td>\n }\n </tr>\n }\n </tbody>\n </table>\n </div>\n }\n @case ('rule') {\n <hr class=\"wr-markdown__rule\" />\n }\n }\n }\n</ng-template>\n\n<ng-container [ngTemplateOutlet]=\"blockList\" [ngTemplateOutletContext]=\"{ $implicit: blocks(), tight: false }\" />\n" }]
|
|
1441
|
+
}], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }], streaming: [{ type: i0.Input, args: [{ isSignal: true, alias: "streaming", required: false }] }], copyable: [{ type: i0.Input, args: [{ isSignal: true, alias: "copyable", required: false }] }], linkTarget: [{ type: i0.Input, args: [{ isSignal: true, alias: "linkTarget", required: false }] }], headingIds: [{ type: i0.Input, args: [{ isSignal: true, alias: "headingIds", required: false }] }], headingIdPrefix: [{ type: i0.Input, args: [{ isSignal: true, alias: "headingIdPrefix", required: false }] }], copyLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "copyLabel", required: false }] }], copiedLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "copiedLabel", required: false }] }], taskDoneLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "taskDoneLabel", required: false }] }], taskTodoLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "taskTodoLabel", required: false }] }] } });
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* @license
|
|
1445
|
+
*
|
|
1446
|
+
* Use of this source code is governed by an MIT-style license that can be
|
|
1447
|
+
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
1448
|
+
*/
|
|
1449
|
+
/**
|
|
1450
|
+
* Give every `<wr-markdown>` in the app a syntax highlighter.
|
|
1451
|
+
*
|
|
1452
|
+
* The highlighter returns coloured SPANS, not HTML — see
|
|
1453
|
+
* {@link WrMarkdownHighlighter} for why that signature is the one worth having.
|
|
1454
|
+
* It may be async, and it may return `null` for languages it does not know; both
|
|
1455
|
+
* render the block as plain text, which is also what server-side rendering
|
|
1456
|
+
* produces, since a highlighter that resolves after prerender has already
|
|
1457
|
+
* finished cannot contribute to the HTML.
|
|
1458
|
+
*
|
|
1459
|
+
* Pass `{ useFactory }` when the adapter needs to inject something. A highlighter
|
|
1460
|
+
* is itself a function, so the factory cannot be detected by shape — hence the
|
|
1461
|
+
* wrapper object rather than an overload that would guess.
|
|
1462
|
+
*
|
|
1463
|
+
* @example
|
|
1464
|
+
* ```ts
|
|
1465
|
+
* bootstrapApplication(App, {
|
|
1466
|
+
* providers: [
|
|
1467
|
+
* provideWrMarkdownHighlighter(shikiSpans),
|
|
1468
|
+
*
|
|
1469
|
+
* // Or, when the adapter has dependencies of its own:
|
|
1470
|
+
* provideWrMarkdownHighlighter({
|
|
1471
|
+
* useFactory: () => {
|
|
1472
|
+
* const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
1473
|
+
* return (code, language) => (isBrowser ? shikiSpans(code, language) : null);
|
|
1474
|
+
* },
|
|
1475
|
+
* }),
|
|
1476
|
+
* ],
|
|
1477
|
+
* });
|
|
1478
|
+
* ```
|
|
1479
|
+
*/
|
|
1480
|
+
function provideWrMarkdownHighlighter(highlighter) {
|
|
1481
|
+
if (typeof highlighter === 'function') {
|
|
1482
|
+
return makeEnvironmentProviders([{ provide: WR_MARKDOWN_HIGHLIGHTER, useValue: highlighter }]);
|
|
1483
|
+
}
|
|
1484
|
+
return makeEnvironmentProviders([{ provide: WR_MARKDOWN_HIGHLIGHTER, useFactory: highlighter.useFactory }]);
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* Generated bundle index. Do not edit.
|
|
1489
|
+
*/
|
|
1490
|
+
|
|
1491
|
+
export { WR_MARKDOWN_HIGHLIGHTER, WrMarkdown, WrMarkdownHighlight, parseInlines, parseMarkdown, plainText, provideWrMarkdownHighlighter, safeMarkdownUrl };
|
|
1492
|
+
//# sourceMappingURL=ngwr-markdown.mjs.map
|