From c1322a23eeacb1d86d4e367e6354fca46f2d3649 Mon Sep 17 00:00:00 2001 From: Marcus Whybrow Date: Thu, 3 Oct 2024 17:04:59 +0100 Subject: [PATCH] Rewrite SSG in Node and frontend in Lit & Vite After deciding on Web Components, Go types had to be duplicated in JSDoc syntax for the JS client code. Although rewriting the static site generation in Node makes SSG slower for a total build, the developer experience can actually be faster because reloading Web Components is fast. Initially native Web Components were working well, but the value of reactive state mounted. Lit Elements add reactive state to Web Component standards, so that, unlike React, it's a small dependency, and requires no build step. The client UI itself is now a two sidebar layout, both attached to the left of the viewport, each scrollable. One for filters, and one for results, with content being loading in the page body. Pagefind performance improves a lot with this commit following the adoption of an IntersectionObserver for loading data as discussed in cloudcannon/pagefind#371. Rurther reductions to initalisation times to follow with the proposed fixes in that issue. Client JS now uses modules (as opposed to synchronous plain JS) in the browser. This approach initially broke support for some older browsers, specifically iOS 16.3 and it's lack of support for import maps. To solve this Vite is now being used to compile client code in a way that's compatible with older browsers. --- .gitignore | 3 + ...esearch-perspectives-on-the-human-brain.md | 60 + assets/1993-00-00-nutrition-for-women.md | 210 ++ ...progesterone-in-orthomolecular-medicine.md | 58 + ...-energy-restoring-the-wholeness-of-life.md | 46 + ...to-menopause-female-hormones-in-context.md | 68 + ...igins-of-authoritarianism-with-ray-peat.md | 12 + .../2023-11-17-tribute-to-dr-raymond-peat.md | 23 +- assets/README.md | 52 - assets/data/cache.yml | 2 +- cmd/ray-peat-rodeo/main.go | 99 +- cmd/ray-peat-rodeo/raypeatpage.templ | 11 + flake.lock | 146 +- flake.nix | 434 ++-- internal/catalog/asset.go | 90 + internal/catalog/catalog.go | 57 +- internal/catalog/peruse.templ | 531 ++--- internal/global/base.templ | 14 +- internal/markdown/ast/ast.go | 1 + internal/markdown/ast/section.go | 49 + internal/markdown/extension/sections.go | 31 + internal/markdown/renderer/section.go | 44 + internal/markdown/renderer/utterance.go | 4 +- internal/markdown/transformer/sections.go | 87 + modd.conf | 25 +- package-lock.json | 1847 +++++++++++++++++ package.json | 41 + src/app.js | 525 +++++ src/client/components/app-root.js | 1074 ++++++++++ src/client/components/rpr-contribution.js | 142 ++ src/client/components/rpr-filter.js | 145 ++ src/client/components/rpr-issue.js | 80 + src/client/components/rpr-sidenote.js | 76 + .../client}/components/rpr-timecode.js | 0 src/client/events.js | 30 + src/client/utils.js | 115 + src/filter-test.js | 13 + src/parser/contributors.js | 69 + src/parser/fetcher.js | 59 + src/parser/index.js | 75 + src/parser/mentions.js | 32 + src/parser/pagefind-meta.js | 10 + src/parser/parser.js | 50 + src/parser/sections.js | 76 + src/parser/sidenotes.js | 81 + src/parser/timecodes.js | 33 + src/parser/utils.js | 35 + .../public}/avatars/andrew-murray.webp | Bin .../public}/avatars/danny-roddy.jpg | Bin .../public}/avatars/david-butterworth.jpg | Bin .../public}/avatars/gary-null.webp | Bin .../public}/avatars/karen-mcc.webp | Bin .../images => src/public}/avatars/male.png | Bin .../public}/avatars/marcus-whybrow.jpg | Bin .../public}/avatars/nicole-behnam.webp | Bin .../public}/avatars/ray-peat.jpg | Bin .../avatars/sarah-johannesen-murray.webp | Bin .../public}/docs/ray-peat-rodeo-banner.png | Bin .../favicons/android-chrome-192x192.png | Bin .../favicons/android-chrome-512x512.png | Bin .../public}/favicons/apple-touch-icon.png | Bin .../public}/favicons/favicon-16x16.png | Bin .../public}/favicons/favicon-32x32.png | Bin .../public}/favicons/favicon.ico | Bin .../public}/favicons/site.webmanifest | 0 {web/static => src/public}/global.css | 448 ++++ .../images/2013-03-07-ray-peat-in-mirror.jpg | Bin .../images/2013-03-07-ray-peat-standing.jpg | Bin .../images/2013-03-07-ray-peat-walking.jpg | Bin .../images/2020-04-26-einstein-quote.jfif | Bin .../public}/images/branching-icon.svg | 0 .../public}/images/edit-round-icon.svg | 0 .../public}/images/exclamation-round-icon.svg | 0 .../public}/images/github-logo.png | Bin .../public}/images/github-mark.svg | 0 .../interface-layout-left-sidebar-icon.svg | 0 .../public}/images/magnifying-glass-icon.svg | 0 .../public}/images/plus-line-icon.svg | 0 .../public}/images/ray-peat-sitting.jpg | Bin .../images/round-line-bottom-arrow-icon.svg | 0 .../public}/images/star-full-icon.svg | 0 src/types/index.d.ts | 253 +++ src/utils.js | 165 ++ web/static/components/rpr-ad.js | 51 - web/static/components/rpr-advanced-search.js | 147 -- web/static/components/rpr-asset-excerpt.js | 65 - web/static/components/rpr-asset.js | 371 ---- web/static/components/rpr-contributor.js | 83 - web/static/components/rpr-deck.js | 65 - web/static/components/rpr-issue.js | 129 -- web/static/components/rpr-layout.js | 216 -- web/static/components/rpr-new-issue.js | 140 -- web/static/components/rpr-pin.js | 206 -- web/static/components/rpr-pins.js | 86 - web/static/components/rpr-ray-peat.js | 19 - web/static/components/rpr-search.js | 831 -------- web/static/components/rpr-sidenote.js | 92 - web/static/components/rpr-toolbar.js | 26 - web/static/components/rpr-utterance.js | 219 -- web/static/scripts/relative-date.js | 27 - web/static/style.css | 528 ----- 101 files changed, 6807 insertions(+), 4125 deletions(-) delete mode 100644 assets/README.md create mode 100644 cmd/ray-peat-rodeo/raypeatpage.templ create mode 100644 internal/markdown/ast/section.go create mode 100644 internal/markdown/extension/sections.go create mode 100644 internal/markdown/renderer/section.go create mode 100644 internal/markdown/transformer/sections.go create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/app.js create mode 100644 src/client/components/app-root.js create mode 100644 src/client/components/rpr-contribution.js create mode 100644 src/client/components/rpr-filter.js create mode 100644 src/client/components/rpr-issue.js create mode 100644 src/client/components/rpr-sidenote.js rename {web/static => src/client}/components/rpr-timecode.js (100%) create mode 100644 src/client/events.js create mode 100644 src/client/utils.js create mode 100644 src/filter-test.js create mode 100644 src/parser/contributors.js create mode 100644 src/parser/fetcher.js create mode 100644 src/parser/index.js create mode 100644 src/parser/mentions.js create mode 100644 src/parser/pagefind-meta.js create mode 100644 src/parser/parser.js create mode 100644 src/parser/sections.js create mode 100644 src/parser/sidenotes.js create mode 100644 src/parser/timecodes.js create mode 100644 src/parser/utils.js rename {web/static/images => src/public}/avatars/andrew-murray.webp (100%) rename {web/static/images => src/public}/avatars/danny-roddy.jpg (100%) rename {web/static/images => src/public}/avatars/david-butterworth.jpg (100%) rename {web/static/images => src/public}/avatars/gary-null.webp (100%) rename {web/static/images => src/public}/avatars/karen-mcc.webp (100%) rename {web/static/images => src/public}/avatars/male.png (100%) rename {web/static/images => src/public}/avatars/marcus-whybrow.jpg (100%) rename {web/static/images => src/public}/avatars/nicole-behnam.webp (100%) rename {web/static/images => src/public}/avatars/ray-peat.jpg (100%) rename {web/static/images => src/public}/avatars/sarah-johannesen-murray.webp (100%) rename {web/static => src/public}/docs/ray-peat-rodeo-banner.png (100%) rename {web/static/images => src/public}/favicons/android-chrome-192x192.png (100%) rename {web/static/images => src/public}/favicons/android-chrome-512x512.png (100%) rename {web/static/images => src/public}/favicons/apple-touch-icon.png (100%) rename {web/static/images => src/public}/favicons/favicon-16x16.png (100%) rename {web/static/images => src/public}/favicons/favicon-32x32.png (100%) rename {web/static/images => src/public}/favicons/favicon.ico (100%) rename {web/static/images => src/public}/favicons/site.webmanifest (100%) rename {web/static => src/public}/global.css (50%) rename {web/static => src/public}/images/2013-03-07-ray-peat-in-mirror.jpg (100%) rename {web/static => src/public}/images/2013-03-07-ray-peat-standing.jpg (100%) rename {web/static => src/public}/images/2013-03-07-ray-peat-walking.jpg (100%) rename {web/static => src/public}/images/2020-04-26-einstein-quote.jfif (100%) rename {web/static => src/public}/images/branching-icon.svg (100%) rename {web/static => src/public}/images/edit-round-icon.svg (100%) rename {web/static => src/public}/images/exclamation-round-icon.svg (100%) rename {web/static => src/public}/images/github-logo.png (100%) rename {web/static => src/public}/images/github-mark.svg (100%) rename {web/static => src/public}/images/interface-layout-left-sidebar-icon.svg (100%) rename {web/static => src/public}/images/magnifying-glass-icon.svg (100%) rename {web/static => src/public}/images/plus-line-icon.svg (100%) rename {web/static => src/public}/images/ray-peat-sitting.jpg (100%) rename {web/static => src/public}/images/round-line-bottom-arrow-icon.svg (100%) rename {web/static => src/public}/images/star-full-icon.svg (100%) create mode 100644 src/types/index.d.ts create mode 100644 src/utils.js delete mode 100644 web/static/components/rpr-ad.js delete mode 100644 web/static/components/rpr-advanced-search.js delete mode 100644 web/static/components/rpr-asset-excerpt.js delete mode 100644 web/static/components/rpr-asset.js delete mode 100644 web/static/components/rpr-contributor.js delete mode 100644 web/static/components/rpr-deck.js delete mode 100644 web/static/components/rpr-issue.js delete mode 100644 web/static/components/rpr-layout.js delete mode 100644 web/static/components/rpr-new-issue.js delete mode 100644 web/static/components/rpr-pin.js delete mode 100644 web/static/components/rpr-pins.js delete mode 100644 web/static/components/rpr-ray-peat.js delete mode 100644 web/static/components/rpr-search.js delete mode 100644 web/static/components/rpr-sidenote.js delete mode 100644 web/static/components/rpr-toolbar.js delete mode 100644 web/static/components/rpr-utterance.js delete mode 100644 web/static/scripts/relative-date.js delete mode 100644 web/static/style.css diff --git a/.gitignore b/.gitignore index c2459e0..fa6ba49 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ build /.direnv /node_modules +/nodebuild +/json +/dist diff --git a/assets/1976-00-00-mind-and-tissue-russian-research-perspectives-on-the-human-brain.md b/assets/1976-00-00-mind-and-tissue-russian-research-perspectives-on-the-human-brain.md index 673ef10..c391ced 100644 --- a/assets/1976-00-00-mind-and-tissue-russian-research-perspectives-on-the-human-brain.md +++ b/assets/1976-00-00-mind-and-tissue-russian-research-perspectives-on-the-human-brain.md @@ -11,3 +11,63 @@ added: author: Marcus Whybrow date: "2024-08-16" --- + +## Introduction by Stanley Krippner, PhD + +The importance of contemporary Soviet psychology has been acknowledged by many authorities. Gardner Murphy and J.K. Kovach, in the third edition of their book, Historical Introduction to Modern Psychology, take up this issue. Speaking of psychology in the U.S.S.R., they describe "the magnitude of its existing and potential impact on the entire body of modern psychology...," stressing the point that "Soviet psychology is not a closed system." These points are reinforced in Mind and Tissue. Its author, Ray Peat, has presented a cogent summary of Soviet psychology, highlighting the contributions it has made to our understanding of human behavior. Peat traces the philosophical origins of Soviet psychology as well as the tradition afforded by Dostoyevski, Tolstoy, and other writers. The gargantuan contributions of Pavlov are described, beginning with the differentiation of the "first signal system," of sense perception from the "second signal system" of language. Ray Peat then emphasizes a point often ignored: Pavlov took the position that the whole organism had to be the subject of scientific study. Since presumably it is the whole organism which is conscious, a psychology which studies isolated behavioral reactions can easily avoid a confrontation with the issue of consciousness. This was not true of Pavlov, who advances ideas concerning sleep, dreaming, hypnosis, and imagery which contributed to our understanding of human abilities. + +Pavlov's successors continue to study brain function, observing that plasticity is the outstanding property of the nervous system. This viewpoint is an optimistic one as it gives parents, educators, and rehabilitationists a chance to develop those in their care by paying attention to the "latent reserves" of a child, student, or patient. + +The reader of Mind and Tissue will enjoy the special discussion of such Soviet psychological concepts as "inhibition," the "orienting reflex," the "effect of person," the effects of magnetic fields on behavior, and the notion of time as a possible source of "energy." The current enthusiasm among some American psychologists for explaining behavior through differences between brain hemispheres could be moderated by examining the Soviet work on cerebral function which emphasizes a more holographic approach. In addition, the Soviet delineating of mental imagery components (space, body awareness, sequencing) leads to an emphasis on meaning and experience in describing brain function. Indeed, it is easy to follow the historical thread that links Pavlov's hypotheses on mental imagery with Lisina's pioneering work in biofeedback. Additional work in what the Soviets call "psychic self-regulation," has been done at Kazakh State University; practical applications of this work are to be found in many Soviet clinics and hospitals. + +Finally, psychology in the U.S.S.R. views creativity not as a psychosexual sublimation or a lack of proper social conditioning but as an essential human trait, part of the need for self-realization through productive work. There is a richness in Soviet psychology that is often overlooked by American scientists. Ray Peat's assertion that materialists emphasize change while idealists emphasize the status quo may well be true. If so, it confirms Murphy's and Kovach's description of Soviet psychology as an open rather than a closed system. + +## Introduction by Peter Marin + +## Preface + +## 1. In the Spirit of Matter + +## 2. The Image + +## 3. The Orienting Reflex + +## 4. Reflecting Conditions + +## 5. Doubleness and Perspective + +## 6. Sexual Energy and the Cortex of the Brain + +## 7. Group Energy + +## 8. Scrotum, Brain and Biological Order + +## 9. A Visual Scanning System and Intentionality + +## 10. Inhibition and Recuperation + +### Note to Chapter 10 + +## 11. Hyperactivity + +## 12. Implied Therapeutic Approaches + +## 13. Ideas on Electrons in Cells + +## 14. Brain and Magnetic Fields + +## 15. Effect of Person + +## 16. Resolution and Transformation + +## 17. Dream Energy + +## 18. The Flow of Energy into the World + +## 19. About Objective Consciousness (No Duality) + +## 20. End of Science? + +## 21. More Generous Perception + +## References diff --git a/assets/1993-00-00-nutrition-for-women.md b/assets/1993-00-00-nutrition-for-women.md index 1bd8eba..b88305d 100644 --- a/assets/1993-00-00-nutrition-for-women.md +++ b/assets/1993-00-00-nutrition-for-women.md @@ -10,3 +10,213 @@ added: author: Marcus Whybrow date: "2024-08-16" --- + +## Preface + +## 1. Hormones + +### Hormones and Physical States + +### Why Prescribe Estrogen? + +### Some Symptoms of Estrogen Excess + +### Estrogen and Sex + +### Vitamin E and Sex + +### Estrogen and Thyroid + +### Thyroid + +### Progesterone in Orthomolecular Medicine + +### Dosage of Progesterone + +### Transdermal Progesterone for Premenstrual Syndrome + +### Topical Progesterone for Acne + +### Progesterone and Body Temperature + +### Breast Soreness. Cystic Ovaries + +## 2. Stress + +### Blood Sugar + +### Stress and Special Nutritional Requirements + +### Sugar and the Pancreas + +### Emotional Problems + +### People Diagnosed As "Psychotic" + +### Cancer Produces Stress + +### Arthritis and Stress + +### Cortisone + +## 3. Aging + +### Aging + +### Menopausal Flushing + +### Similarity of Menopause and Cushing's Syndrome + +### Cholesterol + +### Aging Skin + +### Estrogen and Osteoporosis + +### Blood Pressure — Vitamin E and Other Nutrients + +## 4. Some Diseases + +### Nearsightedness (Myopia) + +### Colitis, Regional Enteritis (Crohn's Disease), Inflammation, and Fibrous Diseases, and "Collagen Disease" + +### Heart Diseases + +### Arteriosclerosis + +### Polio: A Chronology (or isn't science wonderful?) + +### Multiple Sclerosis + +### Infections + +### Food Allergies + +### A Note on Glaucoma + +### Insomnia + +### Low Blood Pressure + +### Skin Feeding + +### Identifying Deficiencies + +### Warburg's Cancer Theory, Cachexia and Thyroid Therapy + +### Cancer, Stress, and Nutrition: A Summary + +### The Cervical Cancer Scare + +### Asthma, Migraine, Psoriasis + +## 5. Pregnancy and Children + +### Age and Pregnancy + +### Precocious Babies + +### Nutrition-Related Ideas For Mothers + +### Iron Sickness + +### Fertility + +### Breast Feeding + +### Brain Damage and the Public Health Protectors + +### Hyperactivity + +### Flouride + +## 6. Diets + +### Appetite + +### Fasting + +### Coffee. Tea, and Colas + +### Natural Vitamins and Minerals - Any Difference? + +### Additives and Quality + +### Warning About Supplements + +### Vitamin C: Many Effects + +### Interactions + +### Cereals, Seeds, and Beans + +### Vinegar, Honey, and Fasting + +### Margarine or Butter + +### Liquid Oils + +### Laxatives + +### Specific Dynamic Action + +### HCG + +### Note for Dieters + +### Popular Reducing Diets + +### Diet Pills + +### Fat: Ideas For Getting Off A Plateau + +### One Woman's Typical Diet for a Day + +### Swelling Up (Edema) + +### Exercise + +### One Megavitamin Program + +### Adapting to a New Diet + +### Energy Itself: CrP and ATP + +### General Principles of Good Nutrition + +## 7. The Future + +### Hidden Motives + +### Disestablish the Professions + +### A Proposed Study + +### Protein and Starvation + +### Reasoning About Health + +### Dietetics or Nutrition? + +### Nutrition and Consciousness + +### About Feelings + +### Desire, The Liberator of Sexual Objects + +### Fertile Pairing + +### from "Evolution as Human Sculpture, 1967 + +## Appendix + +### Some Definitions + +### Units + +### Nutrients: Plant Sources; Places Concentrated in Animals; Functions + +### Holistic Physiology: A Diagram + +### A Note on References diff --git a/assets/1993-00-00-progesterone-in-orthomolecular-medicine.md b/assets/1993-00-00-progesterone-in-orthomolecular-medicine.md index b4d79fd..4073c33 100644 --- a/assets/1993-00-00-progesterone-in-orthomolecular-medicine.md +++ b/assets/1993-00-00-progesterone-in-orthomolecular-medicine.md @@ -8,3 +8,61 @@ added: author: Marcus Whybrow date: "2024-08-17" --- + +## 1. Progesterone's Biological Generality + +### 1.1. Intrinsic general properties + +### 1.2. Steroid precursor function + +### 1.3. Anti-estrogen functions + +### 1.4. Effects on development + +### 1.5. Progesterone and magnesium + +### 1.6. References + +## 2. Steroids + +## 3. Thyroid + +## 4. Warburg's Cancer Theory, Cachexia and Thyroid Therapy + +### 4.1. References + +## 5. The Cervical Cancer Scare + +## 6. Menopause and its Causes + +### 6.1. References + +## 7. Dosage of Progesterone + +### 7.1. References + +## 8. The Progesterone Deceptions + +### 8.1. References + +## 9. Origins of Progesterone Therapy + +### 9.1. Progesterone, the Protective Substance of Youth + +### 9.2. Some Aspects of Basic Progesterone Research + +### 9.3. Practical Issues + +### 9.4. Economic Questions + +### 9.5. References + +## 10. Transdermal Progesterone for Premenstrual Syndrome + +## 11. A List of Signs and Symptoms that Respond to Progesterone Therapy + +### 11.1. References + +## 12. An Efficient Oral Therapy + +### 12.1. References diff --git a/assets/1994-00-00-generative-energy-restoring-the-wholeness-of-life.md b/assets/1994-00-00-generative-energy-restoring-the-wholeness-of-life.md index c2b0582..bc1c12c 100644 --- a/assets/1994-00-00-generative-energy-restoring-the-wholeness-of-life.md +++ b/assets/1994-00-00-generative-energy-restoring-the-wholeness-of-life.md @@ -11,3 +11,49 @@ added: author: Marcus Whybrow date: "2024-08-16" --- + +## Introduction + +## Part One: Aspects of Wholeness + +### 1. Aspects of Wholeness + +### 2. Another View of Evolution + +### 3. Vernadsky's Holistic Science + +### 4. The Centrality of Anticipation + +### 5. The Life of Nature + +### 6. The Ex-Rainforests of the Pacific Northwest + +## Part Two: Energy Problems + +### 7. A Unifying Principle + +### 8. Steroids + +### 9. Thyroid + +### 10. The Stress of Darkness + +### 11. Pregnenolone + +### 12. Restoring Hair Color + +### 13. Arthritis and Hormone Balancing + +### 14. The Carpal Tunnel Syndrome + +### 15. The Premenstrual Syndrome + +### 16. Restoring Fertility + +## Part Three: Regenerating Knowledge + +### 17. Youth, Energy, and Regeneration + +### 18. The Tradition of Truth + +### 19. The Expanding Earth diff --git a/assets/1997-00-00-from-pms-to-menopause-female-hormones-in-context.md b/assets/1997-00-00-from-pms-to-menopause-female-hormones-in-context.md index 4e5b152..9105bd6 100644 --- a/assets/1997-00-00-from-pms-to-menopause-female-hormones-in-context.md +++ b/assets/1997-00-00-from-pms-to-menopause-female-hormones-in-context.md @@ -13,3 +13,71 @@ added: author: Marcus Whybrow date: "2024-08-16" --- + +## Part One: Estrogen In Context + +### 1. Estrogen: The Pill--Simply Dangerous + +### 2. Estrogen: The Hoax of "Replacement" + +### 3. Aging ovaries: Not the eggs + +### 4. Menopause and its Causes + +### 5. Not the "Female Hormone" + +### 6. Just One Problem: Clots + +## Part Two: Progesterone In Context + +### 7. Symptoms that Respond to Progesterone Therapy + +### 8. The Origins of Progesterone Therapy + +### 9. Antiaging Hormones: Steroids in General + +### 10. Youth-Associated Hormones + +### 11. Thyroid + +### 12. Progesterone's Biological Generality + +### 13. Dosage + +### 14. An Efficient Oral Therapy + +### 15. Transdermal Therapy for PMS + +### 16. The Progesterone Deceptions + +## Part Three: "Mysterious" Disease In Context + +### 17. Preserving Tissues: Osteoporosis and the Skin + +### 18. Natural Hormones and Arthritis + +### 19. The Cervical Cancer Scare and Other Approaches to Cancer + +### 20. Warburg's Cancer Theory and Thyroid + +### 21. Migraine, Varicose Veins, & Epilepsy + +### 22. Nerves + +### 23. Alzheimer's Disease + +### 24. Eclampsta in the real Organism + +## Part Four: Some Products in Context + +### 25. Estriol & Phytoestrogens + +### 26. Using Sunlight to Enhance Life + +### 27. Unsaturated Oils: Toxic and Estrogenic + +### 28. The Dangers of Iron: Exacerbated by Estrogen + +### 29. Coconut Oil + +## Conclusion diff --git a/assets/2016-07-13-the-origins-of-authoritarianism-with-ray-peat.md b/assets/2016-07-13-the-origins-of-authoritarianism-with-ray-peat.md index d650b7c..004a1a0 100644 --- a/assets/2016-07-13-the-origins-of-authoritarianism-with-ray-peat.md +++ b/assets/2016-07-13-the-origins-of-authoritarianism-with-ray-peat.md @@ -39,6 +39,8 @@ DR: Hey, how are you? RP: Good! +## Ray's 2003 Newsletter [1:15] + DR: [1:15] Thankyou so much for doing this. I've been interested in this topic for a while and I had a chance to read through [[Peat, Raymond > Public Passivity and the Screw]] and— @@ -52,6 +54,8 @@ DR: It was September 2003 and—I have it in front of me here—you kind of summ RP: Oh, oh yeah. +## Defining Authoritarianism [1:58] + DR: We don't have to go strictly through that, but I was thinking, like, authoritarianism when you talk about it, it seems to be an ambiguous term to a lot of people so I thought before we got into, kind of, the weeds you could talk about what it means to you, and then your general experience with it, maybe growing up, and scientifically. RP: Academically there has been a study now for I guess about 50 years, [[Altemeyer, Robert Anthony|Bob Altemeyer’s]] book [[Altemeyer, Robert Anthony> The Authoritarians]], which came out— I guess he put it on the internet maybe five or ten years ago, and [[Dean III, John Wesley|John Dean]] {Editor's note: Ray said "Bob Dean", which I've ammended to [[Dean III, John Wesley|John Dean]], who I believe was the watergate lawyer he was referring to.} the Watergate lawyer became a fan of it and helped to publicize it because it was about the psychology of political authoritarianism, primarily. And his main focus is on what he calls the right-wing authoritarians. @@ -86,6 +90,8 @@ From the 1920s [[Reich, Wilhelm|Reich]] was saying that there's an emotional pla I think [[Reich, Wilhelm|Reich]] was essentially right, but I think it really is more than just an emotional plague, that I think it's something like a historical, intellectual, epistemological plague built into the way we believe knowledge works. +## Origins of Authoritarianism [9:44] + DR: [9:44] Did it originate from [[Plato]] and [[Aristotle]]? Do you see, like, a origin for this type of oppressive society? RP: Yeah. As soon as I started looking into encyclopaedias, and such, trying to find explanations, I think it goes even before [[Plato]]: [[Parmenides]] and [[Zeno of Elea|Zeno]], they were the Greek school located in southern Italy—[[Eleatics, The]]—and they argued that there is only one reality and it doesn't change, doesn't do anything, there is no movement! @@ -98,6 +104,8 @@ And [[Aristotle]] really acknowledged some of the reality of [[Heraclitus]]. [[H [[Aristotle]] tried to formalize this using some of the concepts of [[Plato]] by putting it into a sensible scheme in which he had different kinds of causality including the ‘final cause principle’ which is that things are moved according to meaning, or intention. The universality of motion has no randomness and everything is meaningful, everything can be seen in terms of the first cause and the final cause and the various causes, consisting of their nature. So that for [[Aristotle]] laws were derived from the nature of things they weren't in some other eternal world like for [[Plato]]. +## The Principle of Forgiveness [12:40] + DR: [12:40] Well that goes back to [[Peat, Raymond > Public Passivity and the Screw | the newsletter]]. You say laws were written in the past and since the past isn't accessible it's almost as though a foreign power ruled from a distant imperial capital. Maybe this goes back to your writing on [[Blake, William|Blake]], and you say that that's a representation of the _past_, and it's trying to retain the past into the current view of things. Is that the problem with not only these laws but politics maybe in general? RP: Oh, oh yeah. The [[Plato|Platonic]] idea is creating a false reality for everything that we have in our memory. Seeing everything in terms of the past—something we have at one time generalized—and then saying that's absolute and unchanging. And what it does is to say that all of these current events are random happenings, they don't contain the meaning in themselves, they only get the meaning from these generalizations, and those are all eternal, out of time. @@ -116,10 +124,14 @@ DR: [16:11] You mentioned Psychopaths before and in the KMUD interview, {#24} an RP: Yeah I think that’s his name. +## Wilhelm Reich [16:40] + DR: You mentioned [[Reich, Wilhelm|Reich]] before, and the connection with [[Dalí, Salvador|Dalí]] and the persistence of memory and [[Dalí, Salvador|Dalí's]] shift, integrating himself into the fascist culture. And you mentioned it started in about 1945. I was curious just to unpack that and your thoughts on the [[Central Intelligence Agency|CIA]], and the Dulles brothers, {[[Dulles, Allen]] was the 5th Director of Central Intelligence during the time his brother [[Dulles, John Foster]] was the 52nd United States Secretary of State.} and anything else you had to say about it. RP: Well 20th century history pretty much involved the Dulles brothers. They were working with [[Hitler, Adolf|Hitler]] with the fascist [[German Reich|Germans]] right from the beginning. And one of them was declared to be an enemy agent, and the [[German Reich|German Government]] had put their [[United States of America|American]] properties in his name, and so the [[United States of America|US Government]], to confiscate the [[United States of America|U.S.]] properties, identified him as an illegal foreign agent. But that didn't take away from their prestige or power, because— you know [[Roosevelt, Franklin Delano|Roosevelt]] himself was a great admirer of [[Mussolini, Benito Amilcare Andrea|Mussolini]], even after he took office he was saying what a great statesman [[Mussolini, Benito Amilcare Andrea|Mussolini]] was. And so fascism wasn't a bad word until they needed to go to war on the side of [[United Kingdom of Great Britain and Northern Ireland|England]] to save the [[United Kingdom of Great Britain and Northern Ireland|British]] Empire because [[Hitler, Adolf|Hitler]] had double-crossed the West and he wanted to knock out [[United Kingdom of Great Britain and Northern Ireland|England]] and take their empire, where the West had figured that he would take over the [[Union of Soviet Socialist Republics|Soviet Union]] and then they would—the [[Union of Soviet Socialist Republics|British]] Empire and the [[United States of America|American]] Empire—could expand. +## Is Authoritarianism A Disease? [18:04] + DR: [18:04] Well going back to saying the Dulles brothers shaped the current predicament were in, do you see authoritarianism as a pathology? Like, this is basically a sickness, and people are imposing their sickness. Is that a good way of looking at it? RP: Yeah, in a sense. A philosopher, I think, would say it's a sickness of method or of conclusions. But if you look at it historically [[Parmenides]], and [[Plato]], and [[Hegel, Georg Wilhelm Friedrich|Hegel]], all of these people were part of the ruling class and it was the purpose of there thinking that way—even though that probably seemed spontaneous and truthful to them—it happened to be that it was a way of defining who deserves power. And defining their class as an eternal justified controller of property and power. diff --git a/assets/2023-11-17-tribute-to-dr-raymond-peat.md b/assets/2023-11-17-tribute-to-dr-raymond-peat.md index cf96040..ed519db 100644 --- a/assets/2023-11-17-tribute-to-dr-raymond-peat.md +++ b/assets/2023-11-17-tribute-to-dr-raymond-peat.md @@ -60,6 +60,8 @@ So from January, this year, {January, 2023.} we've continued doing radio shows a So it's a tribute to Dr Peat, and we sent out a kind of broadcast flyer to people if they wanted to share any of their anecdotal history. And that's basically what the whole show's about for this next hour. So people are invited to call in any point in time from now on. We welcome any of you who've been touched by his philosophy and his approach to medicine. And the number here is 707 923 3911. +## Wanita Remembers Ray Peat [5:23] + [5:23] So the lines are now open. I see there is already a caller. So let's take this first caller. Caller you're on the air. Where you from? What's your question, or what's your, what would you like to share? W: Hi this is Wanita from Reno. I was seen by [[Gambi, Dr|a doctor]] that was a friend of [[Peat, Raymond|Dr Peat]] and he recommended some of [[Peat, Raymond|Dt Peat's]] material to me years and years ago. Did you want me to give you more details? @@ -182,6 +184,8 @@ So just going back to the very beginning of what we, yeah, what we found in 2008 But but nobody knew [[Peat, Raymond|Dr Peat]]! I mean, you couldn't find anything. If you Googled [[Peat, Raymond|Dr Peat]] you wouldn't find a thing. +## Female Caller [17:05] + [17:05] So let me take this next caller. Caller you're on the air. Where you from and what have you got to share? F: Hey Andrew. @@ -278,7 +282,7 @@ And as I got to know [[Peat, Ray|Ray]] and [[Katherine|Katherine]] I realised it AM: Yep. -F: So, you know, I know oftentimes people don't know that much about, you know, [[Peat, Raymond|Rays's]] personal life or [[Katherine|Katherine]] or anything, but he really had a good life. +F: So, you know, I know oftentimes people don't know that much about, you know, [[Peat, Raymond|Ray's]] personal life or [[Katherine|Katherine]] or anything, but he really had a good life. AM: [23:16] Yeah, I never met anybody, I think, who has just given so much and, yeah, he was just so passionate about it that I think _all_ the time [[Peat, Raymond|he]] was by his computer and he was searching stuff, and he was, you know, he was just like a bookworm, an old fashioned bookworm, you know, with a head in a book. @@ -385,12 +389,16 @@ F: Yeah, yeah. AM: That's the bottom line. He was not at all financially motivated I can– be assured of that. can Well thank you so much for calling in and sharing. I just want to make sure that people that are waiting get a chance to share also, but thanks so much for saying what you've said about [[Peat, Raymond|him]] and remembrance of him. +## Ray Peat's Website [31:27] + E: [31:27] So, a caller would like the _spelling_ of [[Peat, Raymond|Dr Peat's]] last name so that they can find stuff online. SJM: Okay, it's– well his website is [[Peat, Raymond > https://raypeat.com | www.raypeat.com]] and that's "R A Y," and then the name is Peat: "P E A T,"–like peat, moss– ".com". So, "PEAT." "RAY PEAT." And now that we're talking about his website, it is in the process of being updated. And you can't order books or newsletters on the website at the moment, but you _can_ now email [[Peat, Raymond > raypeatpublishing@gmail.com]] to order books and order [[Peat, Raymond > Newsletter | newsletters]]. And please remember all of this copyright material is protected, and please respect the copyright, because, he's dead, but his copyright is not. +## Herbs To Thicken The Blood [31:27] + E: And, I foolishly said I'd ask a quick, easy herb question for you guys. Well, maybe easy cos you're a smart group of people: "blood coagulant herbs." Someone had an operation or something and they want herbs that will thicken the blood. AM: Mmm. @@ -421,6 +429,8 @@ AM: But as [inaudible] said, there's one milligram per drop [[Vitamin K2|K2]] pr SJM: That will help [[Coagulant|thicken your blood]] if you want to say it like that. If aspirin [[Anticoagulant|thins your blood]] you can take [[Vitamin K2|vitamin K2]] to help [[Coagulant|thicken your blood]] but it doesn't really thicken your blood because it's, like I said [[Amphoterism|amphoteric]] so it helps keep it regulated, and the company is [[Thorn Research|Thorn Research]]. It does come in [[Medium-Chain Triglyceride|MCT oil]] which a _lot_ of people are allergic to [[Medium-Chain Triglyceride|MCT oil]] even in the one or two drops daily, so you have to use about four times as much topically if you need one drop. So you'd have to use four drops topically rubbed on the inner part of your arm or your inner thighs. +## San Francisco [35:24] + AM: [35:24] Okay, so we have a couple of callers waiting here. Let's take this next caller. Caller you're on the air, where you from, and what would you like to share? @@ -455,6 +465,8 @@ C: and I'm just so grateful that [[Peat, Raymond|he]] was blessed to have [[Kath AM: Yep, it takes two to tango. Well thank you so much for your call. +## Marine County [38:45] + [38:45] I think we have another caller here waiting on the line. So for people that are listening it's a tribute to [[Peat, Raymond|Dr Peat]], it's the first year of his anniversary of his passing, Thanksgiving 2022. The number's 707 923 3911, if people are listening, they want to share their experiences with recommendations that they took from him and how their lives improved thereof then please go ahead and call 923 3911. So let's take this next caller. [39:09] Caller you're on the air, where you from what is your question? @@ -519,6 +531,7 @@ So, just to go back over the, some of the history with [[Peat, Raymond|Dr Peat]] So, whilst that may have been perceived as some kind of a benefit, the [[Toxin|Toxic]] effect of it was the _reason_ why somebody felt like they were getting better. So, to the detriment of their [[Metabolism|metabolic rate]] and their metabolism in general it was just perceived as being a good thing, but he brought out the science {#51} of how dangerous it was and actually to get to the bottom of someone's [[Psoriasis|psoriasis]] or their [[Dermatitis|eczema]] as a matter of fact to be the best way of approaching it. +## Brendon [44:04] [44:04] So, let's just take this next caller. @@ -574,7 +587,9 @@ SJM: Yeah, we have a client who has been taking thyroid for 15 years and they ju AM: I'm mean [[Peat, Raymond|he'd]] always said that the body could just continue manufacturing [[Thyroid Hormone|thyroid hormone]] and that taking supplementation would not block your [[Thyroid|thyroid]] from doing anything again. So it was always there in the background. -[46:44] So there's two more callers lined up. Let's hope that the call wuality is better, because I can barely make out what people are saying here. Hopefully that's not the way it comes over to people listening, and hopefully on the audio live stream it's not that way either. +[46:44] So there's two more callers lined up. Let's hope that the call quality is better, because I can barely make out what people are saying here. Hopefully that's not the way it comes over to people listening, and hopefully on the audio live stream it's not that way either. + +## Kathy Miller [46:44] But let's take this next caller. Caller where you from and what would you like to share? @@ -612,6 +627,8 @@ KM: Okay. All right, well thank you. AM: Yeah, you're welcome. +## Mendocino County [50:24] + [50:24] So let's take another caller. Caller you're on the air. Where you from and what would you like to share. MC: I'm calling from Mendocino County and I'd like to find out the views that you hold about [[Fermented Cod Liver Oil|fermented cod liver oil]]. @@ -652,6 +669,8 @@ Okay. So, as I said we did 136 radio shows with [[Peat, Raymond|Dr Peat]] and I And things that [[Peat, Raymond|he]] came up with– he had a very different approach to [[Cancer|cancer]] as a kind of Hot Topic. But, for example, using [[Iodine|iodine]] topically on [[Cancer|precancerous]] tissues. Things like [[Actinic Keratosis|actinic keratosis]] and other, quote unquote, suspicious skin lesions. Had some very personal very successful effects with that, as also treating [[Basal-Cell Carcinoma|basal-cell carcinoma's]] with [[Dry Ice|dry ice]] and or [[Progesterone|progesterone]] pre-treatment, [[Caffeine|caffeine]], [[Acetylsalicylic Acid|aspirin]] and those kind of other compounds that he had a small kind of formula for, that I used personally, and definitely worked. +## Final Caller [54:51] + [54:51] Let's take the next caller. Caller you're on the air and I think you've got about three minutes before we need to give out details and wrap up the show. FC: Yeah [inaudible], you know, I was thinking about the [[Fatigue|fatigued]]–I'm a young pup at 61–and the [[Fatigue|fatigue]] that I've been noticing, you know (and I'm thinking [[Coronavirus Disease 2019|COVID]]) but I've been taking for the last, maybe, three or four years the [[Omega-3|Omega 3]] and [[Omega-6|Omega 6]] vitamins and stuff with my meals, my breakfast (my small breakfast, if I eat if I eat too much I fall asleep.) diff --git a/assets/README.md b/assets/README.md deleted file mode 100644 index 502acc4..0000000 --- a/assets/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Edge Cases for Consideration - -Should these edge cases be included? Probably from a longer interview already included. - - -Danny Roddy -- [ ] [2016-05-04 Reliable Thyroid Products](https://www.youtube.com/watch?v=VuL4daW_fXY) A 2 minute calip -- [ ] [2016-05-26 A Bioenergetic View of Ketosis in 2-Minutes](https://www.youtube.com/watch?v=H_9UOlXww3o) a 2 minute clip - -Ask Your Herb Doctor -- [ ] [2014-04-18 (21 minute excerpt)](https://www.youtube.com/watch?v=uCAfRC6OmYQ) a 21 minute excerpt (from where?) - -Long Natural Health -- [_] [2014-06-09 Thyroid](https://web.archive.org/web/20141007113008/https://www.longnaturalhealth.com/health-articles/thyroid) Appears to be just a reprint of a chapter from Ray's book From PMS to Menopause. -- [_] [2014-06-19 Pregnenolone](https://web.archive.org/web/20141010091049/https://www.longnaturalhealth.com/health-articles/pregnenolone) Another reprint from From PMD to Menopause. -- [_] [2014-06-20 Comparison of Progesterone and Estrogen](https://web.archive.org/web/20141017002820/http://www.longnaturalhealth.com/health-articles/comparison-progesterone-and-estrogen) Short bullet point comparison by Ray. -- [_] [2014-06-19 Signs & Symptoms That Respond to Progesterone](https://web.archive.org/web/20141006182531/https://www.longnaturalhealth.com/health-articles/signs-symptoms-respond-progesterone) A single list of symptoms, not really an article or interview. - -thyroid.about.com -- [_] [2005-09-19 Thyroid Information](https://web.archive.org/web/20050919235045/http://thyroid.about.com/library/weekly/aa110800c.htm) An article *about* Ray, but not *by* Ray. - -naturodoc.com -- [_] [2006-04-18 Coconut Oil and It's Virtues](https://web.archive.org/web/20060418105748/https://naturodoc.com/library/nutrition/coconut_oil.htm) Reprint from PMS to Menopause. - -Politics & Science -- [ ] [2008-09-18 Reductionist Science (5 minute excerpt)](https://www.toxinless.com/polsci-080918-reductionist-science.mp3) -- [ ] [????-??-?? Machine Scientist (5 minutes)](https://www.functionalps.com/blog/wp-content/uploads/2011/09/Machinist-Scientists.mp3) - -# Sources - -The above list incorporates (or will eventually) the following sources. [Open an issue](https://github.com/marcuswhybrow/ray-peat-rodeo/issues) to suggest more. - -- [x] [MarshmalloW's Maaster List](https://www.selftestable.com/ray-peat-stuff/sites) (as of 2023-02-28) -- [x] [Functional Performance Systems's Master List](https://www.functionalps.com/blog/2011/09/12/) (as of 2023-02-28)master-list-ray-peat-phd-interviews/) -- [ ] [Ray Peat Forums -> Interviews](https://raypeatforum.com/community/forums/interviews.20/) -- [ ] [Ray Peat Forums -> Interview Transcripts](https://raypeatforum.com/community/categories/interview-transcripts.317/) -- [ ] [Ray Peat Forums -> Resources](https://raypeatforum.com/community/forums/resources.233/) -- [ ] [Expulsia.com/health](https://expulsia.com/health) -- [x] [Ray Peat Clips](https://www.youtube.com/channel/UCh4kMDfEon-IAlQcbGym9UQ/videos) (as of 2023-02-28) -- [ ] [Western Botanical Medicine List](https://web.archive.org/web/20160406232157/https://www.westernbotanicalmedicine.com/media.html) (looks like a duplicate) -- [ ] [Chadnet](https://wiki.chadnet.org/ray-peat) -- [ ] [Ray Peat Interview Resources](https://github.com/Ray-Peat/interview/wiki) -- [_] [Ray's Old Website](https://web.archive.org/web/20060423131553/http://www.efn.org/~raypeat/) - -# Non-Pertenant Sources - -Currently focusing on audio/video and written interviews and guest articles. These source currently beyond the scope of this project. Subject to change. - -- [ ] [raypeat.com](https://raypeat.com) -- [ ] Ray Peat's Newsletters -- [ ] [Articles on PubMed](http://www.ncbi.nlm.nih.gov/pubmed/?term=%22Peat+R%22[Author]) -- [ ] [Spanish translations of some of Peat's articles](https://bloqdnotas.blogspot.com/) diff --git a/assets/data/cache.yml b/assets/data/cache.yml index 1b3b2bf..711a0bb 100644 --- a/assets/data/cache.yml +++ b/assets/data/cache.yml @@ -55,7 +55,7 @@ https://pubmed.ncbi.nlm.nih.gov: https://raypeat.com: title: Ray Peat https://twitter.com/WBMherb: - title: This browser is no longer supported. + title: x.com https://web.archive.org/web/20130612080631/http://www.dannyroddy.com/main/an-interview-with-dr-raymond-peat: title: The Danny Roddy Weblog https://web.archive.org/web/20130612080631/https://www.facebook.com/pages/Ray-Peat-Fans/179593648758146: diff --git a/cmd/ray-peat-rodeo/main.go b/cmd/ray-peat-rodeo/main.go index 0055ebe..6b28d6c 100644 --- a/cmd/ray-peat-rodeo/main.go +++ b/cmd/ray-peat-rodeo/main.go @@ -12,15 +12,15 @@ import ( "sync" "time" - // "github.com/marcuswhybrow/ray-peat-rodeo/internal/blog" rprCatalog "github.com/marcuswhybrow/ray-peat-rodeo/internal/catalog" "github.com/marcuswhybrow/ray-peat-rodeo/internal/check" - "github.com/marcuswhybrow/ray-peat-rodeo/internal/global" "github.com/marcuswhybrow/ray-peat-rodeo/internal/utils" ) func main() { + // tStart := time.Now() + if len(os.Args) >= 2 { subcommand := os.Args[1] switch subcommand { @@ -44,6 +44,10 @@ func main() { fmt.Printf("Source: \"%v\"\n", workDir) fmt.Printf("Output: \"%v\"\n", global.BUILD_OUTPUT) + if err := os.RemoveAll(global.BUILD_OUTPUT); err != nil { + log.Fatalf("Failed to clean output directory: %v", err) + } + if err := os.MkdirAll(global.BUILD_OUTPUT, os.ModePerm); err != nil { log.Fatalf("Failed to create output directory: %v", err) } @@ -55,6 +59,8 @@ func main() { // 🗃 Catalog + // log.Printf("Preamble", time.Since(tStart)) + fmt.Println("\n[Files]") fmt.Printf("Source \"%v\"\n", global.ASSETS) @@ -71,6 +77,8 @@ func main() { // 📝 Write files + // log.Printf("Catalog", time.Since(tStart)) + // When an asset filename changes, it's URL changes. // It's nice to redirect old URL's to the new ones. // N.B. this data is currently collected, but not acted upon @@ -95,23 +103,27 @@ func main() { return nil }) - err = catalog.WriteMentionPages() - if err != nil { - log.Fatal("Failed to build mention pages:", err) + // err = catalog.WriteMentionPages() + // if err != nil { + // log.Fatal("Failed to build mention pages:", err) - } - err = catalog.WritePopups() - if err != nil { - log.Fatal("Failed to build mention popup page:", err) + // } + // err = catalog.WritePopups() + // if err != nil { + // log.Fatal("Failed to build mention popup page:", err) - } - err = catalog.WriteSeriesPages() - if err != nil { - log.Fatal("Failed to build asset series pages:", err) - } + // } + // err = catalog.WriteSeriesPages() + // if err != nil { + // log.Fatal("Failed to build asset series pages:", err) + // } + + // log.Printf("Write Files", time.Since(tStart)) slices.SortFunc(completedAssets, rprCatalog.SortAssetsByDateAdded) + // log.Printf("Sort Files", time.Since(tStart)) + // var latestFile *rprCatalog.Asset = nil // if len(completedAssets) > 0 { // latestFile = completedAssets[0] @@ -154,17 +166,33 @@ func main() { component := Stats(prefixes, missing) component.Render(context.Background(), statsPage) + // log.Printf("Stats", time.Since(tStart)) + // 🏠 Homepage indexPage, _ := utils.MakePage(".") component = Index(catalog) component.Render(context.Background(), indexPage) + // log.Printf("Homepage", time.Since(tStart)) + + // Ray Peat Page + + rayPeatPage, _ := utils.MakePage("ray-peat") + component = RayPeatPage() + component.Render(context.Background(), rayPeatPage) + + // log.Printf("Ray Peat Page", time.Since(tStart)) + + // Catalog cache + err = catalog.HttpCache.Write() if err != nil { log.Fatal("Failed to write HTTP cache:", err) } + // log.Printf("Catalog Cache", time.Since(tStart)) + // JSON searchData := []SearchAsset{} for _, asset := range catalog.Assets { @@ -186,6 +214,29 @@ func main() { }) } + sections := []SearchSection{} + for _, section := range asset.Sections { + timecode := (func() *SearchTimecode { + if section.Timecode == nil { + return nil + } + + return &SearchTimecode{ + Hours: section.Timecode.Hours, + Minutes: section.Timecode.Minutes, + Seconds: section.Timecode.Seconds, + } + })() + + sections = append(sections, SearchSection{ + Title: section.Title, + Prefix: section.Prefix, + Level: section.Level, + ID: section.ID, + Timecode: timecode, + }) + } + searchData = append(searchData, SearchAsset{ Path: asset.UrlAbsPath, Title: asset.FrontMatter.Source.Title, @@ -194,6 +245,7 @@ func main() { Date: asset.Date, Contributors: contributors, Issues: issues, + Sections: sections, }) } @@ -203,9 +255,13 @@ func main() { log.Fatal("Failed to write search JSON:", err) } + // log.Printf("JSON", time.Since(tStart)) + // 🏁 Done fmt.Printf("\nFinished in %v.\n", time.Since(start)) + + // log.Printf("Done", time.Since(tStart)) } type SearchContributor struct { @@ -221,6 +277,7 @@ type SearchAsset struct { Date string Contributors []SearchContributor Issues []SearchIssue + Sections []SearchSection } type SearchIssue struct { @@ -228,3 +285,17 @@ type SearchIssue struct { Title string Url string } + +type SearchSection struct { + Prefix []string + Title string + Level int + ID string + Timecode *SearchTimecode +} + +type SearchTimecode struct { + Hours int + Minutes int + Seconds int +} diff --git a/cmd/ray-peat-rodeo/raypeatpage.templ b/cmd/ray-peat-rodeo/raypeatpage.templ new file mode 100644 index 0000000..ab95dff --- /dev/null +++ b/cmd/ray-peat-rodeo/raypeatpage.templ @@ -0,0 +1,11 @@ +package main + +import "github.com/marcuswhybrow/ray-peat-rodeo/internal/global" + +templ RayPeatPage() { + @global.Base("Ray Peat") { + + + + } +} diff --git a/flake.lock b/flake.lock index 1a0fe94..557a2e8 100644 --- a/flake.lock +++ b/flake.lock @@ -1,83 +1,12 @@ { "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1710146030, - "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "flake-utils_2": { - "inputs": { - "systems": "systems_2" - }, - "locked": { - "lastModified": 1694529238, - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "gomod2nix": { - "inputs": { - "flake-utils": "flake-utils_2", - "nixpkgs": "nixpkgs" - }, - "locked": { - "lastModified": 1722589758, - "narHash": "sha256-sbbA8b6Q2vB/t/r1znHawoXLysCyD4L/6n6/RykiSnA=", - "owner": "nix-community", - "repo": "gomod2nix", - "rev": "4e08ca09253ef996bd4c03afa383b23e35fe28a1", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "gomod2nix", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1658285632, - "narHash": "sha256-zRS5S/hoeDGUbO+L95wXG9vJNwsSYcl93XiD0HQBXLk=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "5342fc6fb59d0595d26883c3cadff16ce58e44f3", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "master", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_2": { - "locked": { - "lastModified": 1724479785, - "narHash": "sha256-pP3Azj5d6M5nmG68Fu4JqZmdGt4S4vqI5f8te+E/FTw=", + "lastModified": 1726937504, + "narHash": "sha256-bvGoiQBvponpZh8ClUcmJ6QnsNKw0EMrCQJARK3bI1c=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d0e1602ddde669d5beb01aec49d71a51937ed7be", + "rev": "9357f4f23713673f310988025d9dc261c20e70c6", "type": "github" }, "original": { @@ -87,76 +16,9 @@ "type": "github" } }, - "nixpkgs_3": { - "locked": { - "lastModified": 1712604496, - "narHash": "sha256-Ye1R+k60uo0B3mn5xwsgb0PzceSg9y0E2OMYQnizLD0=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "ed6db4c403cc2fc4924d24e1a2a2f148c6152620", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, "root": { "inputs": { - "flake-utils": "flake-utils", - "gomod2nix": "gomod2nix", - "nixpkgs": "nixpkgs_2", - "tailwind-scrollbar": "tailwind-scrollbar" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "systems_2": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, - "tailwind-scrollbar": { - "inputs": { - "nixpkgs": "nixpkgs_3" - }, - "locked": { - "lastModified": 1712669471, - "narHash": "sha256-ZzkwGzLPu+mdArFMZ0Q5zAqpu+okJ6bglqJ4O4Uq1M8=", - "owner": "marcuswhybrow", - "repo": "tailwind-scrollbar", - "rev": "0d503aecc218fabec019013951911359634c4be0", - "type": "github" - }, - "original": { - "owner": "marcuswhybrow", - "repo": "tailwind-scrollbar", - "type": "github" + "nixpkgs": "nixpkgs" } } }, diff --git a/flake.nix b/flake.nix index 3d43fab..f537542 100644 --- a/flake.nix +++ b/flake.nix @@ -3,255 +3,255 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; - gomod2nix.url = "github:nix-community/gomod2nix"; - tailwind-scrollbar.url = "github:marcuswhybrow/tailwind-scrollbar"; }; - outputs = inputs: with inputs; flake-utils.lib.eachDefaultSystem (system: let + outputs = inputs: let pkgs = import inputs.nixpkgs { - overlays = [inputs.gomod2nix.overlays.default]; - inherit system; + system ="x86_64-linux"; }; in { - # https://github.com/nix-community/gomod2nix/blob/master/docs/nix-reference.md - packages = rec { - ray-peat-rodeo = pkgs.buildGoApplication { - name = "ray-peat-rodeo"; - pwd = ./.; - src = ./.; - modules = ./gomod2nix.toml; - - buildPhase = '' - mkdir -p $out/bin - ${pkgs.templ}/bin/templ generate - go build ./cmd/ray-peat-rodeo - mv ray-peat-rodeo $out/bin/ray-peat-rodeo - ''; - - meta = { - description = "Custom static-site-generator. Ran from this repo it consumes markdown files in `./assets` and produces HTML files in `./build`."; - maintainers = [ - "Marcus Whybrow " - ]; - homepage = "https://raypeat.rodeo"; - }; - }; - - build = pkgs.stdenv.mkDerivation { - pname = "build"; - version = "unstable"; - src = ./.; - - buildInputs = [ - inputs.tailwind-scrollbar.packages.x86_64-linux.default - pkgs.nodejs_20 - ]; - - buildPhase = '' - ${self.packages.${system}.ray-peat-rodeo}/bin/ray-peat-rodeo - ${pkgs.pagefind}/bin/pagefind --site ./build - ${pkgs.nodePackages.tailwindcss}/bin/tailwindcss \ - --config ./tailwind.config.js \ - --minify \ - --output ./build/assets/tailwind.css - cp -r ./web/static/* ./build/assets - - # mkdir --parents ./build/assets/static/scripts - # cp ${self.packages.${system}.ufuzzy}/src/uFuzzy.js ./build/assets/static/scripts/uFuzzy.js - - mv ./build $out - ''; - - meta = { - description = "Creates the final website deployment by running ray-peat-rodeo, pagefind static search, tailwind CSS processing, and copying raw assets into place."; - maintainers = [ - "Marcus Whybrow " - ]; - homepage = "https://github.com/marcuswhybrow/ray-peat-rodeo"; - }; - }; - - whisper-json2md = pkgs.buildGoApplication { - name = "whisper-json2md"; - pwd = ./.; - src = ./.; - modules = ./gomod2nix.toml; - - buildPhase = '' - mkdir -p $out/bin - go build ./cmd/whisper-json2md - mv whisper-json2md $out/bin/whisper-json2md - ''; - - meta = { - description = "Takes a Whisper AI JSON file and your name and outputs markdown to stdout appropriate to append to Ray Peat Rodeo markdown file."; - homepage = "https://github.com/marcuswhybrow/ray-peat-rodeo"; - maintainers = [ - "Marcus Whybrow " - ]; - }; - }; - - transcribe = pkgs.writeScriptBin "transcribe" '' - set -o xtrace - - asset_path="$1" - author="$2" - start="''${3:-0}" - - asset_name=$(basename "$asset_path") - source_url=$(${pkgs.yq-go}/bin/yq ".source.url | select(.)" "$asset_path") - - tmp_dir_audio=$(mktemp --directory) - audio_path="$tmp_dir_audio/$asset_name" - - ${pkgs.yt-dlp}/bin/yt-dlp -x "$source_url" -o "$audio_path" - audio_name_actual=$(ls -AU "$tmp_dir_audio" | head -1) - audio_path_actual="$tmp_dir_audio/$audio_name_actual" - - if [ "$start" != "0" ] then - ${pkgs.ffmpeg}/bin/ffmpeg -ss "$start" -i "$audio_path_actual" "$audio_path_actual-trimmed" - audio_path_actual="$audio_path_actual-trimmed" - fi - - ls "$tmp_dir_audio" - - tmp_dir_json=$(mktemp --directory) - ${pkgs.openai-whisper}/bin/whisper --language English --fp16 False --output_format json --output_dir "$tmp_dir_json" "$audio_path_actual" - json_name=$(ls -AU "$tmp_dir_json" | head -1) - json_path="$tmp_dir_json/$json_name" - - today=$(date +"%Y-%m-%d") - yq="${pkgs.yq-go}/bin/yq --front-matter process --inplace" - $yq ".transcription.date = \"$today\"" "$asset_path" - $yq ".transcription.author = \"Whisper AI\"" "$asset_path" - $yq ".transcription.kind = \"auto-generated\"" "$asset_path" - $yq ".added.author = \"$author\"" "$asset_path" - $yq ".added.date = \"$today\"" "$asset_path" - $yq ".completion.content = true" "$asset_path" - ${inputs.self.packages.x86_64-linux.whisper-json2md}/bin/whisper-json2md "$json_path" "$start" >> "$asset_path" - - rm -r "$tmp_dir_audio" - rm -r "$tmp_dir_json" - ''; + packages.x86_64-linux.ray-peat-rodeo-node = pkgs.buildNpmPackage { + pname = "ray-peat-rodeo-node"; + dontNpmBuild = true; + version = "0.0.0"; + src = ./.; + npmDepsHash = "sha256-Upe+DtmfEmjVAIKMV0bbjYgLeSsPC8EEOdNHblAKVRI="; + # npmDepsHash = pkgs.lib.fakeHash; + }; - # https://github.com/leeoniya/uFuzzy - ufuzzy = pkgs.fetchFromGitHub { - owner = "leeoniya"; - repo = "uFuzzy"; - rev = "1.0.14"; - hash = "sha256-g70bBIYc2CWMXVGKKXd1EgcomOJ0CnS3wTYAQWQS0fg="; - }; - - # https://github.com/GoogleChromeLabs/text-fragments-polyfill - text-fragments-polyfill = pkgs.fetchFromGitHub { - owner = "GoogleChromeLabs"; - repo = "text-fragments-polyfill"; - rev = "53375fea08665bac009bb0aa01a030e065c3933d"; # 2024-01-09 - hash = "sha256-iKIuA10f/oDPj0AVUZOSuI7z+YpHsL1SUVal/hdBBOM="; - }; - - set-zero-timeout = pkgs.fetchFromGitHub { - owner = "shahyar"; - repo = "setZeroTimeout-js"; - rev = "5547e33b873d535ebd69f489be7102912e889eaf"; - hash = "sha256-K42Tz3xN6lf2XKeLlNUSVAGt3hcQZRoNItf71i88z3o="; - }; - - copy-assets = pkgs.writeShellScriptBin "copy-assets" '' - echo "Copying ./web/static" - mkdir --parents ./build/assets - cp -rf ./web/static/* ./build/assets - - echo "Copying uFuzzy" - mkdir --parents ./build/assets/static/scripts - cp -f ${self.packages.${system}.ufuzzy}/dist/uFuzzy.iife.min.js \ - ./build/assets/scripts - - echo "Copying text-fragments-polyfill" - mkdir --parents ./build/assets/scripts/text-fragments-polyfill - cp -f ${self.packages.${system}.text-fragments-polyfill}/src/* \ - ./build/assets/scripts/text-fragments-polyfill - - echo "Copying setZeroTimeout.js" - mkdir --parents ./build/assets/scripts - cp -f ${self.packages.${system}.set-zero-timeout}/setZeroTimeout.min.js \ - ./build/assets/scripts + packages.x86_64-linux.build = pkgs.stdenv.mkDerivation { + pname = "build"; + version = "0.0.0"; + src = ./.; + buildInputs = [ pkgs.nodejs ]; + buildPhase = '' + mkdir $out + node ${inputs.self.packages.x86_64-linux.ray-peat-rodeo-node}/lib/node_modules/ray-peat-rodeo/src/app.js + mv ./build/* $out ''; + }; - default = build; + # packages.x86_64-linux.ray-peat-rodeo = pkgs.buildGoApplication { + # name = "ray-peat-rodeo"; + # pwd = ./.; + # src = ./.; + # modules = ./gomod2nix.toml; + + # buildPhase = '' + # mkdir -p $out/bin + # ${pkgs.templ}/bin/templ generate + # go build ./cmd/ray-peat-rodeo + # mv ray-peat-rodeo $out/bin/ray-peat-rodeo + # ''; + + # meta = { + # description = "Custom static-site-generator. Ran from this repo it consumes markdown files in `./assets` and produces HTML files in `./build`."; + # maintainers = [ + # "Marcus Whybrow " + # ]; + # homepage = "https://raypeat.rodeo"; + # }; + # }; + + # packages.x86_64-linux.build = pkgs.stdenv.mkDerivation { + # pname = "build"; + # version = "unstable"; + # src = ./.; + + # buildInputs = [ + # inputs.tailwind-scrollbar.packages.x86_64-linux.default + # pkgs.nodejs_20 + # ]; + + # buildPhase = '' + # ${inputs.self.packages.x86_64-linux.ray-peat-rodeo}/bin/ray-peat-rodeo + # ${pkgs.pagefind}/bin/pagefind --site ./build + # ${pkgs.nodePackages.tailwindcss}/bin/tailwindcss \ + # --config ./tailwind.config.js \ + # --minify \ + # --output ./build/assets/tailwind.css + # cp -r ./web/static/* ./build/assets + + # # mkdir --parents ./build/assets/static/scripts + # # cp ${inputs.self.packages.x86_64-linux.ufuzzy}/src/uFuzzy.js ./build/assets/static/scripts/uFuzzy.js + + # mv ./build $out + # ''; + + # meta = { + # description = "Creates the final website deployment by running ray-peat-rodeo, pagefind static search, tailwind CSS processing, and copying raw assets into place."; + # maintainers = [ + # "Marcus Whybrow " + # ]; + # homepage = "https://github.com/marcuswhybrow/ray-peat-rodeo"; + # }; + # }; + + # packages.x86_64-linux.whisper-json2md = pkgs.buildGoApplication { + # name = "whisper-json2md"; + # pwd = ./.; + # src = ./.; + # modules = ./gomod2nix.toml; + + # buildPhase = '' + # mkdir -p $out/bin + # go build ./cmd/whisper-json2md + # mv whisper-json2md $out/bin/whisper-json2md + # ''; + + # meta = { + # description = "Takes a Whisper AI JSON file and your name and outputs markdown to stdout appropriate to append to Ray Peat Rodeo markdown file."; + # homepage = "https://github.com/marcuswhybrow/ray-peat-rodeo"; + # maintainers = [ + # "Marcus Whybrow " + # ]; + # }; + # }; + + packages.x86_64-linux.transcribe = pkgs.writeScriptBin "transcribe" /* bash */ '' + set -o xtrace + + asset_path="$1" + author="$2" + start="''${3:-0}" + + asset_name=$(basename "$asset_path") + source_url=$(${pkgs.yq-go}/bin/yq ".source.url | select(.)" "$asset_path") + + tmp_dir_audio=$(mktemp --directory) + audio_path="$tmp_dir_audio/$asset_name" + + ${pkgs.yt-dlp}/bin/yt-dlp -x "$source_url" -o "$audio_path" + audio_name_actual=$(ls -AU "$tmp_dir_audio" | head -1) + audio_path_actual="$tmp_dir_audio/$audio_name_actual" + + if [ "$start" != "0" ] then + ${pkgs.ffmpeg}/bin/ffmpeg -ss "$start" -i "$audio_path_actual" "$audio_path_actual-trimmed" + audio_path_actual="$audio_path_actual-trimmed" + fi + + ls "$tmp_dir_audio" + + tmp_dir_json=$(mktemp --directory) + ${pkgs.openai-whisper}/bin/whisper --language English --fp16 False --output_format json --output_dir "$tmp_dir_json" "$audio_path_actual" + json_name=$(ls -AU "$tmp_dir_json" | head -1) + json_path="$tmp_dir_json/$json_name" + + today=$(date +"%Y-%m-%d") + yq="${pkgs.yq-go}/bin/yq --front-matter process --inplace" + $yq ".transcription.date = \"$today\"" "$asset_path" + $yq ".transcription.author = \"Whisper AI\"" "$asset_path" + $yq ".transcription.kind = \"auto-generated\"" "$asset_path" + $yq ".added.author = \"$author\"" "$asset_path" + $yq ".added.date = \"$today\"" "$asset_path" + $yq ".completion.content = true" "$asset_path" + ${inputs.self.packages.x86_64-linux.whisper-json2md}/bin/whisper-json2md "$json_path" "$start" >> "$asset_path" + + rm -r "$tmp_dir_audio" + rm -r "$tmp_dir_json" + ''; + + # https://github.com/leeoniya/uFuzzy + packages.x86_64-linux.ufuzzy = pkgs.fetchFromGitHub { + owner = "leeoniya"; + repo = "uFuzzy"; + rev = "1.0.14"; + hash = "sha256-g70bBIYc2CWMXVGKKXd1EgcomOJ0CnS3wTYAQWQS0fg="; }; - # Run `nix develop` to enter a shell containing all dependencies. - # One may use nix-direnv to auto load said shell on cd into project. - devShells.default = pkgs.mkShell { - name = "ray-peat-rodeo-devshell"; - packages = with pkgs; [ - (pkgs.writeScriptBin "build" '' - # Echo commands to stdout before running - set -o xtrace - - templ generate && \ - go run ./cmd/ray-peat-rodeo && \ - pagefind --site ./build && \ - tailwind \ - --config ./tailwind.config.js \ - --minify \ - --output ./build/assets/tailwind.css && \ - cp -r ./web/static/* ./build/assets - '') + # https://github.com/GoogleChromeLabs/text-fragments-polyfill + packages.x86_64-linux.text-fragments-polyfill = pkgs.fetchFromGitHub { + owner = "GoogleChromeLabs"; + repo = "text-fragments-polyfill"; + rev = "53375fea08665bac009bb0aa01a030e065c3933d"; # 2024-01-09 + hash = "sha256-iKIuA10f/oDPj0AVUZOSuI7z+YpHsL1SUVal/hdBBOM="; + }; + + packages.x86_64-linux.set-zero-timeout = pkgs.fetchFromGitHub { + owner = "shahyar"; + repo = "setZeroTimeout-js"; + rev = "5547e33b873d535ebd69f489be7102912e889eaf"; + hash = "sha256-K42Tz3xN6lf2XKeLlNUSVAGt3hcQZRoNItf71i88z3o="; + }; - # Add "go" command with correct modules in environment - # https://github.com/nix-community/gomod2nix/blob/master/docs/nix-reference.md - (mkGoEnv { - pwd = ./.; # wordking directory - modules = ./gomod2nix.toml; - }) + packages.x86_64-linux.copy-static = pkgs.writeShellScriptBin "copy-static" '' + OUT="$1" - # Translates go.mod packages into a nix expression. - gomod2nix + echo "Copying ./src/client" + cp -rf ./src/client/* "$OUT" - # Compiles .templ files into .go files - templ + echo "Copying ./src/public" + mkdir --parents "$OUT/public" + cp -rf ./src/public/* "$OUT/public" - # Builds JS search API by inspecting HTML build by this package - pagefind + echo "Copying text-fragments-polyfill" + mkdir --parents "$OUT/public/scripts/text-fragments-polyfill" + cp -f ${inputs.self.packages.x86_64-linux.text-fragments-polyfill}/src/* \ + "$OUT/public/scripts/text-fragments-polyfill" - # NodeJS is needed to for Tailwind plugins to be found - nodejs_20 + echo "Copying uFuzzy" + cp -f ${inputs.self.packages.x86_64-linux.ufuzzy}/dist/uFuzzy.iife.min.js \ + "$OUT/public/scripts" - # Scrollbar styling plugin for TaildindCSS - inputs.tailwind-scrollbar.packages.x86_64-linux.default + echo "Copying setZeroTimeout.js" + cp -f ${inputs.self.packages.x86_64-linux.set-zero-timeout}/setZeroTimeout.min.js \ + "$OUT/public/scripts" + ''; - # Builds CSS utility classes by inspecting template source code - nodePackages.tailwindcss + + packages.x86_64-linux.pull-types = pkgs.writeShellScriptBin "pull-types" '' + out="./types" + echo "Copying Pagefind type definitions to $out"; + mkdir --parents $out + cp -f ${inputs.self.packages.x86_64-linux.pagefind}/pagefind_web_js/types/index.d.ts $out + ''; + + packages.x86_64-linux.default = inputs.self.packages.x86_64-linux.build; + + devShells.x86_64-linux.default = pkgs.mkShell { + name = "ray-peat-rodeo-node-devshell"; + packages = [ + # NodeJS is needed to for Tailwind plugins to be found + pkgs.nodejs # Dev tools to watch the files system and rerun (above) commands - modd + pkgs.modd # Dev HTTP server with auto page reload on file changes - devd + pkgs.devd # AI transcription of audio files - openai-whisper + pkgs.openai-whisper # For download's audio files from any URL - yt-dlp + pkgs.yt-dlp + + inputs.self.packages.x86_64-linux.copy-static # Custom tool to convert Whisper JSON output to our markdown format - inputs.self.packages.x86_64-linux.whisper-json2md + # inputs.self.packages.x86_64-linux.whisper-json2md # Convenience bash script using yt-dlp, whisper & whisper-json2md to # transcribe and update assets with a `source.url` in the frontmatter. - inputs.self.packages.x86_64-linux.transcribe - - # Allows modd to copy assets from nix packages - inputs.self.packages.x86_64-linux.copy-assets + # inputs.self.packages.x86_64-linux.transcribe # Get text for PDF assets that don't have it - # ocrmypdf + # pkgs.ocrmypdf + + (pkgs.writeShellScriptBin "dev" '' + ${pkgs.inotify-tools}/bin/inotifywait \ + --monitor \ + --recursive \ + --event create \ + --event close_write . | + while read -r dir action file; do + since="$(timeout 0.1s cat)" + x="$(printf "$dir\n$(printf "$since" | awk -F' ' '{print $1}')")" + y=$(printf "$x" | awk '!x[$0]++') + echo "$y" + done + '') ]; }; - }); + }; } diff --git a/internal/catalog/asset.go b/internal/catalog/asset.go index 6200719..68f2d8e 100644 --- a/internal/catalog/asset.go +++ b/internal/catalog/asset.go @@ -11,6 +11,7 @@ import ( "path/filepath" "slices" "strings" + "time" "github.com/gernest/front" "github.com/mitchellh/mapstructure" @@ -75,6 +76,26 @@ type Asset struct { // A list of Speaker's derrvied from this asset's frontmatter. Speakers []*Speaker + + Sections []*AssetSection +} + +type AssetTimecode struct { + Hours int + Minutes int + Seconds int +} + +func (t *AssetTimecode) ToString() string { + return fmt.Sprintf("%02d:%02d:%02d", t.Hours, t.Minutes, t.Seconds) +} + +type AssetSection struct { + ID string + Level int + Prefix []string + Title string + Timecode *AssetTimecode } type AssetFrontMatterSource struct { @@ -115,6 +136,7 @@ type AssetFrontMatter struct { } func NewAsset(assetPath string, markdownParser goldmark.Markdown, httpCache *cache.HTTPCache, avatarPaths *AvatarPaths) (*Asset, error) { + tStart := time.Now() fileName := filepath.Base(assetPath) fileStem := strings.TrimSuffix(fileName, filepath.Ext(assetPath)) @@ -124,6 +146,7 @@ func NewAsset(assetPath string, markdownParser goldmark.Markdown, httpCache *cac } // 🔗 Details + tDetails := time.Now() id := fileStem[11:] urlAbsPath := "/" + id @@ -132,6 +155,7 @@ func NewAsset(assetPath string, markdownParser goldmark.Markdown, httpCache *cac outPath := path.Join(id, "index.html") // 📄 FrontMatter + tFrontMatter := time.Now() matter := front.NewMatter() matter.Handle("---", front.YAMLHandler) @@ -147,6 +171,7 @@ func NewAsset(assetPath string, markdownParser goldmark.Markdown, httpCache *cac } // 👨👱 Speakers & Avatars + tSpeakers := time.Now() speakers := []*Speaker{} for id, name := range frontMatter.Speakers { @@ -188,6 +213,7 @@ func NewAsset(assetPath string, markdownParser goldmark.Markdown, httpCache *cac } // 🖥 HTML + tHTML := time.Now() parserContext := gparser.NewContext() parserContext.Set(ast.AssetKey, asset) @@ -199,6 +225,16 @@ func NewAsset(assetPath string, markdownParser goldmark.Markdown, httpCache *cac return nil, fmt.Errorf("Failed to parse markdown: %v", err) } asset.Html = html.Bytes() + + log.Printf( + "Preamble %v, Details %v, FrontMatter %v, Speakers & Avatars %v, HTML %v", + tDetails.Sub(tStart).Milliseconds(), + tFrontMatter.Sub(tDetails).Milliseconds(), + tSpeakers.Sub(tFrontMatter).Milliseconds(), + tHTML.Sub(tSpeakers).Milliseconds(), + time.Since(tHTML).Milliseconds(), + ) + return asset, nil } @@ -311,6 +347,23 @@ func (a *Asset) GetMirrorDomains() []string { return domains } +func (a *Asset) GetLocations() []string { + locations := []string{} + locations = append(locations, a.FrontMatter.Source.Url) + locations = append(locations, a.FrontMatter.Source.Mirrors...) + return locations +} + +func (a *Asset) AcceptsTimecodes() bool { + switch a.FrontMatter.Source.Kind { + case "audio": + return true + case "video": + return true + } + return false +} + // Implement ast.File interface // Returns the raw source markdown (without any file frontmatter) @@ -329,6 +382,43 @@ func (a *Asset) RegisterMention(mention *ast.Mention) { a.Mentionables[mention.Mentionable] = append(a.Mentionables[mention.Mentionable], mention) } +func (a *Asset) RegisterSection(section *ast.Section) string { + timecode := (func() *AssetTimecode { + if !a.AcceptsTimecodes() { + return nil + } + + if section.Timecode == nil { + log.Fatalf("Section '%v' does not specify timecode in asset %v", section.Title, a.Path) + } + + return &AssetTimecode{ + Hours: section.Timecode.Hours, + Minutes: section.Timecode.Minutes, + Seconds: section.Timecode.Seconds, + } + })() + + proposal := &AssetSection{ + Level: section.Level, + Title: section.Title, + Prefix: section.Prefix, + ID: section.ID(), + Timecode: timecode, + } + + for _, section := range a.Sections { + if section.ID == proposal.ID { + log.Fatalf(`Heading '%v' in '%v' has the ID '%v' which matches a + previous section's ID. Consider making each heading's title more + unique.`, proposal.Title, proposal.ID, a.Path) + } + } + + a.Sections = append(a.Sections, proposal) + return proposal.ID +} + func (a *Asset) GetSpeakers() []ast.Speaker { speakers := make([]ast.Speaker, len(a.Speakers)) for i, s := range a.Speakers { diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 0d4f4ac..873490a 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -69,6 +69,7 @@ func NewCatalog(assetsPath string) *Catalog { extension.Speakers, extension.Sidenotes, extension.GitHubIssues, + extension.Sections, ), ) @@ -135,34 +136,34 @@ func (c *Catalog) NewAsset(filePath string) (*Asset, error) { c.Speakers = append(c.Speakers, speaker.GetName()) } - for mentionable, mentions := range asset.Mentionables { - for existingMentionable, existingByFile := range c.ByMentionable { - if mentionable.IsDuplicate(existingMentionable) { - if mentionable.IsMoreComplex(existingMentionable) { - suspect := anyValue(existingByFile)[0] - suggestion := mentions[0] - collisionPanic(suspect, suggestion) - } else { - suspect := mentions[0] - suggestion := anyValue(existingByFile)[0] - collisionPanic(suspect, suggestion) - } - } - } - - if c.ByMentionable[mentionable] == nil { - c.ByMentionable[mentionable] = ByAsset[Mentions]{} - } - c.ByMentionable[mentionable][asset] = mentions - - if c.ByMentionablePart[mentionable.Primary] == nil { - c.ByMentionablePart[mentionable.Primary] = ByPart[ByAsset[Mentions]]{} - } - if c.ByMentionablePart[mentionable.Primary][mentionable.Secondary] == nil { - c.ByMentionablePart[mentionable.Primary][mentionable.Secondary] = ByAsset[Mentions]{} - } - c.ByMentionablePart[mentionable.Primary][mentionable.Secondary][asset] = mentions - } + // for mentionable, mentions := range asset.Mentionables { + // for existingMentionable, existingByFile := range c.ByMentionable { + // if mentionable.IsDuplicate(existingMentionable) { + // if mentionable.IsMoreComplex(existingMentionable) { + // suspect := anyValue(existingByFile)[0] + // suggestion := mentions[0] + // collisionPanic(suspect, suggestion) + // } else { + // suspect := mentions[0] + // suggestion := anyValue(existingByFile)[0] + // collisionPanic(suspect, suggestion) + // } + // } + // } + + // if c.ByMentionable[mentionable] == nil { + // c.ByMentionable[mentionable] = ByAsset[Mentions]{} + // } + // c.ByMentionable[mentionable][asset] = mentions + + // if c.ByMentionablePart[mentionable.Primary] == nil { + // c.ByMentionablePart[mentionable.Primary] = ByPart[ByAsset[Mentions]]{} + // } + // if c.ByMentionablePart[mentionable.Primary][mentionable.Secondary] == nil { + // c.ByMentionablePart[mentionable.Primary][mentionable.Secondary] = ByAsset[Mentions]{} + // } + // c.ByMentionablePart[mentionable.Primary][mentionable.Secondary][asset] = mentions + // } c.Mutex.Unlock() diff --git a/internal/catalog/peruse.templ b/internal/catalog/peruse.templ index ee1b91c..2a91b89 100644 --- a/internal/catalog/peruse.templ +++ b/internal/catalog/peruse.templ @@ -2,371 +2,218 @@ package catalog import ( + "encoding/json" "fmt" "github.com/marcuswhybrow/ray-peat-rodeo/internal/global" "github.com/marcuswhybrow/ray-peat-rodeo/internal/utils" - "net/url" + "log" "strings" ) templ Peruse(asset *Asset, catalog *Catalog, pagefind bool) { @global.Base(asset.FrontMatter.Source.Title) { -
- -
- -
-
-
    - for _, speaker := range asset.GetFilterableSpeakers() { -
  • { speaker.GetName() }
  • - } -
-
    - for _, domain := range asset.GetMirrorDomains() { -
  • { domain }
  • - } -
-
    - if asset.FrontMatter.Completion.Issues { -
  • Issues
  • - } - if asset.FrontMatter.Completion.Content { -
  • Content
  • - } - if asset.FrontMatter.Completion.Mentions { -
  • Mentions
  • - } - if asset.FrontMatter.Completion.Timestamps { -
  • Timestamps
  • - } - if asset.FrontMatter.Completion.ContentVerified { -
  • Content Verified
  • - } - if asset.FrontMatter.Completion.SpeakersIdentified { -
  • Speakers Identified
  • - } - if asset.FrontMatter.Completion.Notes { -
  • Notes
  • - } -
-
    - for mentionable, _ := range asset.Mentionables { -
  • { mentionable.AsSignature() }
  • - } -
-
-
-

- - { asset.Date } - - + +

+
+
    + for _, speaker := range asset.GetFilterableSpeakers() { +
  • { speaker.GetName() }
  • + } +
+
    + for _, domain := range asset.GetMirrorDomains() { +
  • { domain }
  • + } +
+
    + if asset.FrontMatter.Completion.Issues { +
  • Issues
  • + } + if asset.FrontMatter.Completion.Content { +
  • Content
  • + } + if asset.FrontMatter.Completion.Mentions { +
  • Mentions
  • + } + if asset.FrontMatter.Completion.Timestamps { +
  • Timestamps
  • + } + if asset.FrontMatter.Completion.ContentVerified { +
  • Content Verified
  • + } + if asset.FrontMatter.Completion.SpeakersIdentified { +
  • Speakers Identified
  • + } + if asset.FrontMatter.Completion.Notes { +
  • Notes
  • + } +
+
+
-
-
- - -
- @utils.Unsafe(string(asset.Html)) -
- if !asset.FrontMatter.Completion.Content { -
- -
-

- if asset.FrontMatter.Source.Url != "" { - This { strings.ToLower(asset.GetFriendlyKind()) } is + { asset.Date } + + +

+
+
+
+ +
+ @utils.Unsafe(string(asset.Html)) +
+ if !asset.FrontMatter.Completion.Content { +
+ +
+

+ if asset.FrontMatter.Source.Url != "" { + This { strings.ToLower(asset.GetFriendlyKind()) } is + available on { utils.UrlHostname(asset.FrontMatter.Source.Url) }, + but hasn't yet been added to Ray Peat Rodeo. + if asset.FrontMatter.Transcription.Url != "" { + See too, this available on { utils.UrlHostname(asset.FrontMatter.Source.Url) }, - but hasn't yet been added to Ray Peat Rodeo. - if asset.FrontMatter.Transcription.Url != "" { - See too, this - existing transcript. - } - } else { - This { strings.ToLower(asset.GetFriendlyKind()) } is missing from Ray Peat Rodeo. - if asset.FrontMatter.Transcription.Url != "" { - However, there is an - existing transcript - } + href={ templ.URL(asset.FrontMatter.Transcription.Url) } + target="_blank" + >existing transcript. } - If you're familiar with Markdown, you can - edit this page via a GitHub account, and submit your - changes as a pull request. Or - Support me on GitHub Sponsors { "if" } you like 😊. -

-
+ } else { + This { strings.ToLower(asset.GetFriendlyKind()) } is missing from Ray Peat Rodeo. + if asset.FrontMatter.Transcription.Url != "" { + However, there is an + existing transcript + } + } + If you're familiar with Markdown, you can + edit this page via a GitHub account, and submit your + changes as a pull request. Or + Support me on GitHub Sponsors { "if" } you like 😊. +

- } -
-
- - - - - + + } + +
+
Title{ asset.FrontMatter.Source.Title }
+ + + + + + + + + + + + + if asset.FrontMatter.Added.Author != "" { - - + + - - + + - if asset.FrontMatter.Added.Author != "" { - - - - - - - - - } - - - + + + + + + - - - - - - - - - - - - - -
Title{ asset.FrontMatter.Source.Title }
Kind{ asset.FrontMatter.Source.Kind }
Series{ asset.FrontMatter.Source.Series }
Kind{ asset.FrontMatter.Source.Kind }Added by{ asset.FrontMatter.Added.Author }
Series{ asset.FrontMatter.Source.Series }Added on{ asset.FrontMatter.Added.Date }
Added by{ asset.FrontMatter.Added.Author }
Added on{ asset.FrontMatter.Added.Date }
Locations + } +
Locations +
    + for _, mirror := range asset.GetLocations() { +
  • { mirror }
  • + } +
+
Participants + if len(asset.Speakers) == 0 { + - + } else { -
Participants - if len(asset.Speakers) == 0 { - - - } else { -
    - for _, contributor := range asset.Speakers { -
  • { contributor.GetName() }
  • - } -
- } -
GitHub Edit Link{ asset.GitHubEditUrl }
GitHub Raw Link{ asset.GithubRawUrl }
-
-
-
+ } + + + + GitHub Edit Link + { asset.GitHubEditUrl } + + + GitHub Raw Link + { asset.GithubRawUrl } + + + +
} } -type AssetData struct { - Title string - Date string - AbsURL string - Kind string -} - -func assetsToJSON(assets []*Asset) []AssetData { - data := []AssetData{} - for _, asset := range assets { - data = append(data, AssetData{ - Title: asset.FrontMatter.Source.Title, - Date: asset.Date, - AbsURL: asset.UrlAbsPath, - Kind: asset.FrontMatter.Source.Kind, - }) - } - return data -} - -type MapEnumeration[V any] struct { - Key string - Index int - Value V - IsFirst bool - IsLast bool -} - -// Utility for converting a map into a slice appropriate for rendering to a -// human readable list. -func MapToSlice[V any](m map[string]V) []MapEnumeration[V] { - i := 0 - l := len(m) - var results []MapEnumeration[V] - - for key, value := range m { - results = append(results, MapEnumeration[V]{ - Index: i, - Key: key, - Value: value, - IsFirst: i == 0, - IsLast: i == l-1, - }) - i++ +func toJSON(i any) string { + s, err := json.Marshal(i) + if err != nil { + log.Fatalf("Failed to marshal JSOn for %v: %v", i, err) } - - return results -} - -// Small note in the flow of, but distinct from, speaker messages. -templ StatusChange() { -
- { children... } -
+ return string(s) } diff --git a/internal/global/base.templ b/internal/global/base.templ index 5b4c7af..fc12af3 100644 --- a/internal/global/base.templ +++ b/internal/global/base.templ @@ -6,13 +6,14 @@ func gitHubLink() string { templ Base(title string) { - + // Doesn't respect location changes must fix + if len(title) > 0 { { title + " - Ray Peat Rodeo" } } else { - { "Ray Peat Rodeo" } + Ray Peat Rodeo } @@ -29,7 +30,8 @@ templ Base(title string) { - + + @@ -44,6 +46,8 @@ templ Base(title string) { + + + + + + + + + + + + + + ${partial} +
    + ${results.join("")} +
+
+ + + `; + + /** @type {Page} */ + const page = { + asset, partial, + html: content, + filters: extractPagefindFilters(partial), + }; + + return page; +}); + +console.log(`Detertimed data for all pages by ${since()}ms`); + +/** @type {PagefindFilters} */ +let filters = {}; + +pageList.forEach(page => { + for (const [name, values] of Object.entries(page.filters)) { + for (const [value, count] of Object.entries(values)) { + if (!filters.hasOwnProperty(name)) filters[name] = {}; + if (!filters[name].hasOwnProperty(value)) filters[name][value] = count; + else filters[name][value] += count; + } + } +}); + +writeBuffer.push(() => { + fs.mkdirSync(`${OUT}/derived`, { recursive: true }); + fs.writeFileSync( + `${OUT}/derived/filters.js`, + `export const FILTERS = ${JSON.stringify(filters)};` + ); +}); + +filters = Object.fromEntries(Object.entries(filters) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([key, values]) => [ + key, + Object.fromEntries(Object.entries(values) + .sort((a, b) => a[0].localeCompare(b[0])) + ) + ]) +); + +const { index: pagefind } = await pf.createIndex(); +assert(pagefind); + +const latestPagePartial = pageList.reduce((latest, page) => { + const partialFilename = outPartialFilename(page.asset.slug); + if (!fs.existsSync(partialFilename)) return latest; + const { mtime } = fs.statSync(partialFilename); + const value = mtime.valueOf(); + if (value > latest) return value; + return latest; +}, 0); + +let rebuildPagefindIndex = false; + +if (fs.existsSync(BUILD_CACHE)) { + const prevLatestPagePartial = fs.readFileSync(BUILD_CACHE, "utf8"); + console.log("prev", JSON.parse(prevLatestPagePartial)); + console.log("curr", latestPagePartial); + if (latestPagePartial > JSON.parse(prevLatestPagePartial)) { + rebuildPagefindIndex = true; + } +} else { + rebuildPagefindIndex = true; +} + +writeBuffer.push(() => fs.writeFileSync(BUILD_CACHE, JSON.stringify(latestPagePartial))); + +let indexingPromiseList = []; +if (rebuildPagefindIndex) { + indexingPromiseList = pageList.map(async page => { + await pagefind.addHTMLFile({ + url: `/${page.asset.slug}`, + content: html` + + + ${he.escape(page.asset.frontMatter.source.title)} + ${page.partial} + + `, + }); + }); +} + +const writePromiseList = pageList.map(async (page, index) => { + const out = outHtmlFilename(page.asset.slug); + const partial = outPartialFilename(page.asset.slug); + const parsed = path.parse(out); + + writeBuffer.push(() => { + fs.mkdirSync(parsed.dir, { recursive: true }); + fs.writeFileSync(out, page.html); + }); + + if (fs.existsSync(partial)) { + const extant = fs.readFileSync(partial, "utf8"); + + // Preserving file modified is important for caching. + if (page.partial !== extant) { + writeBuffer.push(() => fs.writeFileSync(partial, page.partial)); + } + } else { + writeBuffer.push(() => fs.writeFileSync(partial, page.partial)); + } + + writeBuffer.push(() => fs.copyFileSync(page.asset.filename, outMarkdownFilename(page.asset.slug))); + + // Make home page a copy of the latest asset + if (index === 0) { + writeBuffer.push(() => fs.writeFileSync(path.join(OUT, "index.html"), page.html)); + } +}); + +const derivedDone = Promise.all(assetPromiseList).then(assetList => { + writeBuffer.push(() => fs.mkdirSync(`${OUT}/derived`, { recursive: true })); + + /** @type {ThinAsset[]} */ + const thinAssetList = assetList.map(asset => ({ + title: asset.frontMatter.source.title, + slug: asset.slug, + date: asset.date, + publisher: asset.frontMatter.source.series, + sections: asset.sections, + issues: asset.issues, + })); + + const assetsJson = Object.fromEntries(assetList.map(asset => [asset.slug, `/${asset.slug}.json`])); + + const assetInputs = assetList + .map(asset => `"${asset.slug}": resolve(__dirname, "${asset.slug}/index.html"),`) + .join("\n"); + + const viteConfigJs = js` + import {resolve} from "path" + import {defineConfig} from "vite" + + export default defineConfig({ + build: { + rollupOptions: { + input: { + main: resolve(__dirname, "index.html"), + ${assetInputs} + }, + }, + }, + }) + `; + + writeBuffer.push(() => { + fs.mkdirSync(`${OUT}/public/derived`, { recursive: true }); + fs.writeFileSync(`${OUT}/public/assets.json`, JSON.stringify(assetsJson)); + fs.writeFileSync(`${OUT}/derived/thin-assets.js`, `export default ${JSON.stringify(thinAssetList)};`); + fs.writeFileSync(path.join(OUT, "vite.config.js"), viteConfigJs); + }); +}); + +const pagefindDone = Promise.all(indexingPromiseList).then(async () => { + if (rebuildPagefindIndex) { + writeBuffer.push(async () => { + await pagefind.writeFiles({ outputPath: `${OUT}/public/pagefind` }) + fs.mkdirSync(`${OUT}/pagefind`, { recursive: true }); + fs.renameSync(`${OUT}/public/pagefind/pagefind.js`, `${OUT}/pagefind/pagefind.js`); + console.log(`Wrote Pagefind files to ${OUT}/pagefind/ by ${since()}ms`); + }); + } else { + console.log(`Pagefind files already cached at ${OUT}/pagefind/ by ${since()}ms`); + } +}); + +await Promise.all([...writePromiseList, pagefindDone, derivedDone]).then(async () => { + for (const action of writeBuffer) await action(); + console.log(`Completed in ${since()}ms`); +}); diff --git a/src/client/components/app-root.js b/src/client/components/app-root.js new file mode 100644 index 0000000..9981d59 --- /dev/null +++ b/src/client/components/app-root.js @@ -0,0 +1,1074 @@ +import { LitElement, html, css } from "lit"; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import { createRef, ref } from "lit/directives/ref.js"; +import { ContextProvider, createContext } from "@lit/context"; +import { FILTERS } from "/derived/filters.js"; +import ASSETS from "/derived/thin-assets.js"; +import * as pagefind from "/pagefind/pagefind.js"; +import { ResultClickEvent } from "../events.js"; +import { isVisible, loadResultData, pagefindFilters } from "../utils.js"; + +/** @type {import("@lit/context").Context<"activeFilters", Filter[]>} */ +export const activeFiltersContext = createContext("activeFilters"); + +/** @type {import("@lit/context").Context<"availableFilters", PagefindFilters>} */ +export const availableFiltersContext = createContext("availableFilters"); + +export class AppRoot extends LitElement { + /** @type {Result[]} */ + result + + static properties = { + _results: { state: true }, + // _allFilters: { state: true }, + + activeFilters: { + type: Array, + state: true, + + /** @type {(newVal:Filter[], oldVal:Filter[]) => Boolean} */ + hasChanged: (newVal, oldVal) => { + if (!oldVal) return true; + if (newVal.length !== oldVal.length) return true; + const alphaSort = (a, b) => `${a.key}${a.value}`.localeCompare(`${b.key}${b.value}`); + newVal.sort(alphaSort); + oldVal.sort(alphaSort); + const itemMissmatch = (filter, i) => filter.key !== oldVal[i].key || filter.value !== oldVal[i].value; + return newVal.some(itemMissmatch); + }, + }, + + _activeResultSlug: { state: true }, + _activeResultSectionId: { state: true }, + + _search: { state: true }, + results: { state: true }, + }; + + /** + * Get the active filters in the form pagefind expects. + * + * @returns {Object.} + */ + get pagefindFilters() { + return pagefindFilters(this.activeFilters); + } + + constructor() { + super(); + + /** @type {PagefindResponse|null} */ + this.pagefindResponse = null; + + /** + * This is a hack to build a reverse lookup table from Pagefind's result + * url to result id. + * + * This won't be necessary once issue 371 is fixed. + * - https://github.com/CloudCannon/pagefind/issues/371 + * + * @type {Promise<[url: string, id: string][]>} + */ + this.pagefindReverseLookup = new Promise(async resolve => { + /** @type {PagefindResponse} */ + const response = await pagefind.search(null); + + resolve(await Promise.all(response.results.map(async result => { + await new Promise(r => setZeroTimeout(r, 0)); + const data = await result.data(); + return [data.url, result.id]; + }))); + }); + + this.initialising = true; + this.searchInputSet = false; + this.allowHistoryChanges = false; + this.activeFiltersProvider = new ContextProvider(this, { context: activeFiltersContext }); + this.availableFiltersProvider = new ContextProvider(this, { context: availableFiltersContext }); + this.showInitResults = true; + + /** @type {import("lit-html/directives/ref.js").Ref} */ + this.searchInput = createRef(); + + + /** @type {PagefindFilters} */ + this._allFilters = FILTERS; + this.availableFiltersProvider.setValue(FILTERS); + + /** @type {Result[]} */ + this.results = ASSETS; + + pagefind.filters(); // Required to receive filters henceforth + + const params = new URLSearchParams(window.location.search); + const activeFilters = []; + + for (const [key, values] of Object.entries(this._allFilters)) { + for (const [value, _count] of Object.entries(values)) { + if (params.has(key, value)) { + activeFilters.push({ key, value }); + } + } + } + + this._search = params.get("search") || ""; + + this.activeFilters = activeFilters; + this.activeFiltersProvider.setValue(activeFilters); + + this.initialising = false; + + window.addEventListener("filter-click", async event => { + const activeFilters = structuredClone(this.activeFilters); + const index = activeFilters.findIndex(f => f.key === event.key && f.value === event.value); + if (index >= 0) { + if (event.force === null) { + activeFilters.push({ key: event.key, value: event.value }); + } else if (event.force === false) { + activeFilters.splice(index, 1); + } + } else if (event.force === null || event.force === true) { + activeFilters.push({ key: event.key, value: event.value }); + } + + this.activeFilters = activeFilters + }); + + window.addEventListener("result-click", async event => { + const url = new URL(window.location.href); + const currentSlug = url.pathname.replace(/^\/+(.*?)\/*$/, "$1"); + const currentSection = url.hash.substring(1); + + // Update Window Location + + url.pathname = `/${event.slug}/`; // trailing slash prevents 301 + url.hash = event.section || ""; + + if (currentSlug !== event.slug || currentSection !== event.section) { + history.pushState({}, "", url.href); + } + + // Highlight Result In List + + this.shadowRoot + ?.querySelectorAll(`.result[data-slug="${event.slug}"] .header`) + .forEach(element => element.ariaCurrent = "true"); + + this.shadowRoot + ?.querySelectorAll(`.result:not([data-slug="${event.slug}"]) .header`) + .forEach(element => element.ariaCurrent = "false"); + + + // Replace Content + + if (currentSlug !== event.slug) { + const response = await fetch(`/${event.slug}/partial.html`); + if (!response.ok) throw new Error(`Failed to fetch result content "${url.pathname}".`); + this.innerHTML = await response.text(); + } + + // Scroll to Section or Top + + if (event.section) { + const SCROLL_OFFSET = 100; + const section = this.querySelector(`[id="${event.section}"]`); + if (!section) throw new Error(`Failed to find section "${event.section}" in result content "${url.pathname}".`); + window.scrollTo({ + top: section.getBoundingClientRect().top + window.scrollY - SCROLL_OFFSET, + }); + } else { + window.scrollTo({ top: 0 }); + } + }); + + window.addEventListener("popstate", event => { + }); + + window.addEventListener("keydown", event => { + const inputEl = /** @type {HTMLInputElement|null} */ (this.shadowRoot?.querySelector(".search input")); + if (inputEl) { + if (event.key === "k" && event.ctrlKey) { + inputEl.focus(); + inputEl.select(); + event.preventDefault(); + event.stopPropagation(); + } else if (event.key === "Escape") { + inputEl.blur(); + } + } + }); + } + + connectedCallback() { + super.connectedCallback(); + + /** @type {IntersectionObserverCallback} */ + const intersectionHandler = entries => { + if (this.pagefindResponse) { + const ids = entries.map(entry => { + const htmlElement = /** @type {HTMLElement} */ (entry.target); + return htmlElement.dataset.pagefindResultId || ""; + }); + + loadResultData(this.results, this.pagefindResponse.results, ids) + .then(results => this.results = results); + } + }; + + this.observer = new IntersectionObserver(intersectionHandler, { + root: null, + rootMargin: "200px", + threshold: 0, + }); + + this.observer?.takeRecords + } + + /** @param {import("lit").PropertyValues} changedProperties */ + async willUpdate(changedProperties) { + if (changedProperties.has("activeFilters")) { + this.activeFiltersProvider.setValue(this.activeFilters); + } + + const searchChanged = changedProperties.has("_search"); + const searchPrev = changedProperties.get("_search"); + const searchIsInit = typeof searchPrev === "undefined"; + const dirtySearch = searchChanged && (!searchIsInit || this._search !== ""); + + const activeFiltersChanges = changedProperties.has("activeFilters"); + const activeFiltersPrev = changedProperties.get("activeFilters"); + const activeFiltersIsInit = typeof activeFiltersPrev === "undefined"; + const dirtyActiveFilters = activeFiltersChanges && (!activeFiltersIsInit || this.activeFilters.length > 0); + + const performSearch = dirtySearch || dirtyActiveFilters; + + // console.log("app-root willUpdate", changedProperties, { + // buffering: this.buffering, + // _search: this._search, + // results: this.results, + // activeFilters: this.activeFilters, + // _allFilters: this._allFilters, + // }); + + if (performSearch) { + /** @type {PagefindResponse} */ + const response = await pagefind.search(this._search || null, { + filters: pagefindFilters(this.activeFilters || []), + + // Sort by date if no search text is provided, otherwise sort by weight (default); + sort: this._search ? {} : { date: "desc" }, + }); + + if (response !== null) { + this.pagefindResponse = response; + this.results = await Promise.all(this.results.map(async result => { + result.loaded = false; + + // Hack until Pagefind issue 371 is fixed. + if (!result.pagefindResultId) { + const lookup = await this.pagefindReverseLookup; + const entry = lookup.find(l => l[0] === `/${result.slug}`); + if (!entry) return result; + result.pagefindResultId = entry[1]; + } + + const index = response.results.findIndex(pr => pr.id === result.pagefindResultId); + result.score = response.results[index]?.score; + result.order = index; + return result; + })); + this._allFilters = response.filters; + this.availableFiltersProvider.setValue(response.filters); + this.showInitResults = false; + + const all = Array.from(document.querySelectorAll(".results .result")); + + // Maybe be unessary after every search, requires investigation + all.forEach(element => { + this.observer?.observe(element); + }); + + const ids = all.filter(e => isVisible(e, 100)).map(element => { + const htmlElement = /** @type {HTMLElement} */ (element); + return htmlElement.dataset.pagefindResultId || ""; + }); + + this.results = await loadResultData(this.results, response.results, ids); + } + } + } + + render() { + const url = new URL(window.location.href); + if (this._search) { + url.searchParams.set("search", this._search); + } else { + url.searchParams.delete("search"); + } + + /** + * @param {String} key + * @param {Object.} values + */ + const renderFilter = (key, values) => { + return Object.entries(values).map(([value, count]) => { + const active = (this.activeFilters || []).some(f => f.key === key && f.value === value); + const extant = url.searchParams.has(key, value); + + if (active && !extant) { + url.searchParams.append(key, value); + } else if (!active && extant) { + url.searchParams.delete(key, value); + } + + return html` +
  • + +
  • + `; + }); + }; + + /** @param {Event} event */ + const toggleFilterMode = event => { + const elem = /** @type {HTMLElement} */ (event.target); + if (elem.textContent === "or") { + elem.textContent = "and"; + } else { + elem.textContent = "or"; + } + }; + + /** @type {import("lit-html").TemplateResult[]} */ + const filters = Object.entries(this._allFilters).map(([key, values]) => { + return html` +
  • +
    + ${key} + or +
    +
      + ${renderFilter(key, values)} +
    +
  • + `; + }); + + if (this.allowHistoryChanges) { + history.replaceState({}, "", url.href); + } + + if (this.initialising === false) { + this.allowHistoryChanges = true; + } + + /** @type {(event:MouseEvent) => void} */ + const resetFilters = event => { + this.activeFilters = []; + this._search = ""; + if (this.searchInput.value) this.searchInput.value.value = ""; + event.preventDefault(); + event.stopPropagation(); + }; + + const resetFiltersButton = (() => { + if (filters.length > 0) { + return html` +
  • + Reset Filters +
  • + `; + } + })(); + + const renderIssues = this.activeFilters?.some(f => f.key === "issues" && f.value === "Has Issues") || false; + + /** @type {import("lit-html").TemplateResult[]} */ + const results = this.results.map(result => { + const slugFromUrl = window.location.pathname.replace(/^\/+(.*?)\/$$/, "$1"); + const current = result.slug === slugFromUrl; + + /** @type {(event:Event) => void} */ + const onClick = event => { + event.preventDefault(); + event.stopPropagation(); + event.target?.dispatchEvent(new ResultClickEvent(result.slug)); + }; + + return html` +
  • + +

    ${result.title}

    +

    ${result.date} ${result.publisher}

    +
    + + ${result.excerpt ? html`
    ${unsafeHTML(result.excerpt)}
    ` : ""} + + ${renderIssues && result.issues + ? html`
      ${issuesToHtml(result.slug, result.issues)}
    ` + : ""} + +
      +
      +
      + ${sectionsToHtml(result.slug, result.sections, renderIssues)} +
    +
  • + `; + }); + + /** @type {(event:InputEvent) => void} */ + const searchKeyUp = event => { + const inputEl = /** @type {HTMLInputElement} */ (this.searchInput.value); + this._search = inputEl.value; + }; + + return html` + + +
    + Ray Peat Rodeo +
      + ${filters} +

      Advanced Search

      + + ${resetFiltersButton} +
    +
    + + ${this.results.length > 0 ? html`
      ${results}
    ` : ""} + ${this.showInitResults ? html`` : ""} + +
    +
    + +
    +
    + `; + } + + updated() { + this.shadowRoot?.querySelectorAll(".results .result").forEach(result => { + this.observer?.observe(result); + }); + + if (!this.searchInputSet && this.searchInput.value) { + this.searchInput.value.value = this._search; + this.searchInputSet = true; + } + } + + static styles = css` + :host(*) { + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --sidebar: 20rem; + } + + .search { + border-bottom: 1px solid var(--slate-200); + padding: 1rem 2rem; + top: 0; + position: sticky; + z-index: 50; + background: rgba(255, 255, 255, 0.8); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + margin-left: calc(var(--sidebar) * 2 + 2px); + } + + .search .search-input { + position: relative; + width: 20rem; + display: flex; + flex-direction: row; + transition: all .05s; + } + .search .search-input:has(input:focus), + .search .search-input:has(input:not(:placeholder-shown)) { + width: 25rem; + } + + .search .search-input input { + padding: 0.5rem 1rem 0.5rem 2.5rem; + width: 100%; + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + letter-spacing: 0.025em; + border: none; + border: 1px solid var(--slate-200); + border-radius: 9999px; + background: white; + } + .search .search-input input:placeholder { + opacity: 1; + color: var(--slate-200); + } + + .search .search-input kbd { + position: absolute; + right: 1rem; + top: 0; + bottom: 0; + display: grid; + align-items: center; + color: var(--slate-400); + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + } + + .search .search-input svg { + position: absolute; + top: 0.55rem; + left: 0.5rem; + + height: 1.25rem; + width: 1.25rem; + opacity: 0.5; + } + + .all-filters { + position: fixed; + top: 0; + left: 0; + width: var(--sidebar); + height: 100vh; + overflow-y: scroll; + border-right: 1px solid var(--slate-200); + } + + .results { + position: fixed; + top: 0; + left: calc(var(--sidebar) + 1px); + width: var(--sidebar); + height: 100vh; + overflow-y: scroll; + + border-right: 1px solid var(--slate-200); + } + + .main { + margin-left: calc(var(--sidebar) * 2 + 2px); + position: relative; + } + .main::before { + content: ""; + position: absolute; + background: radial-gradient(at top, var(--pink-50) 0%, transparent 70%); + top: -8rem; + left: 0; + right: 30%; + height: 60rem; + z-index: -10; + } + .main::after { + content: ""; + position: absolute; + background: radial-gradient(at top, var(--purple-50) 0%, transparent 70%); + top: -8rem; + left: 20%; + right: 20%; + height: 30rem; + z-index: -11; + } + + + .main .content { + width: 40rem; + margin: 0 auto; + } + + @media (max-width: 1330px) { + .main .content { + width: auto; + min-width: 20rem; + margin: 0; + padding-right: 2rem; + padding-left: 2rem; + } + } + + .all-filters { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + @media (max-width: 1330px) { + :host { + --sidebar: 15rem; + } + } + + .all-filters a.home { + font-size: var(--font-size-3xl); + line-height: var(--line-height-3xl); + margin: 2rem 2rem 1rem 2rem; + text-decoration: none; + font-weight: 700; + color: var(--slate-900); + letter-spacing: -0.05em; + } + + + .all-filters ul { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 1rem; + } + + .all-filters > ul { + padding: 0rem 2rem 2rem 2rem; + display: grid; + grid-template-columns: min-content auto min-content; + } + + .all-filters { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .all-filters > ul:not(:has(rpr-filter[pertinant])):before { + content: "No results"; + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + color: var(--slate-400); + grid-column: 1 / -1; + } + + .all-filters > ul:not(:has(rpr-filter[pertinant])) .group { + display: none; + } + + .all-filters > ul .reset { + display: none; + } + + .all-filters > ul:not(:has(rpr-filter[pertinant])) .reset { + display: block; + } + .all-filters .reset a { + display: inline-block; + text-decoration: none; + background: var(--slate-500); + color: white; + font-weight: 400; + border-radius: 9999px; + padding: 0.5rem 1rem; + letter-spacing: 0.025em; + font-size: var(--font-size-md); + line-height: var(--line-height-md); + transition: all .1s; + } + .all-filters .reset a:hover { + box-shadow: 0 5px 30px rgba(0,0,0,0.3); + transform: translateY(3px); + } + + .all-filters .group .filters:not(:has(rpr-filter[pertinant])):after { + content: "No results"; + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + color: var(--slate-400); + grid-column: 1 / -1; + } + + .all-filters .group { + width: 100%; + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + } + + .all-filters .group .header { + grid-column: 1 / -1; + display: grid; + grid-template-columns: auto max-content; + padding: 0.5rem 0; + } + + .all-filters .group .header .name { + grid-column: 1; + font-size: var(--font-size-md); + line-height: var(--line-height-md); + color: var(--slate-900); + text-transform: capitalize; + letter-spacing: -0.025em; + } + + .all-filters .group .header .filter-mode { + grid-column: 2; + color: var(--slate-400); + background: var(--slate-100); + border-radius: 9999px; + font-size: var(--font-size-xs); + line-height: var(--line-height-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 0 0.5rem; + display: grid; + align-items: center; + cursor: pointer; + transform: translateX(0.5rem); + user-select: none; + } + .all-filters .group .header .filter-mode:hover { + background: var(--slate-200); + color: var(--slate-500); + } + + .all-filters .group ul.filters { + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + row-gap: 0; + column-gap: 0.5rem; + } + + .all-filters .group ul.filters li { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + } + + .all-filters .group .filters rpr-filter { + margin-bottom: 0.5rem; + } + + .all-filters rpr-filter { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + align-items: start; + } + + .all-filters rpr-filter::part(wrapper) { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + align-items: start; + } + .all-filters rpr-filter::part(checkbox) { + margin-top: 0.3rem; + grid-column: 1; + } + .all-filters rpr-filter::part(value) { + grid-column: 2; + } + .all-filters rpr-filter::part(count) { + grid-column: 3; + } + + .all-filters rpr-filter[count="0"]:not([active]) { + display: none; + } + + .results { + margin: 0; + padding: 0; + + display: flex; + flex-direction: column; + list-style: none; + } + + .results .result { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem 2rem; + } + + .results .result[data-score=""] { + display: none; + } + + .results .issues { + display: grid; + grid-template-columns: min-content auto; + column-gap: 0.5rem; + row-gap: 0.5rem; + } + .results .issues .issue { + grid-column: 1 / -1; + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + } + .results .section .issues .issue { + border-left: 1px solid var(--amber-100); + padding-left: 1rem; + } + .results .section .issues .issue:last-child { + padding-bottom: 1rem; + } + .results .issues .id { + color: var(--amber-600); + font-weight: 700; + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + margin-right: 0.25rem; + letter-spacing: -0.025em; + } + .results .issues .id:before { + content: "#"; + } + .results .issues .title { + display: inline; + border: 0; + padding: 0; + color: var(--amber-600); + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + } + + .results .result .excerpt:not(:empty) { + font-size: var(--font-size-xs); + line-height: var(--line-height-xs); + padding-bottom: 0.5rem; + } + + .results .result.hide:not(:has([aria-current="true"])) { + display: none; + } + + .results .result a.header { + display: flex; + flex-direction: column; + gap: 0.5rem; + color: var(--slate-800); + text-decoration: none; + } + .results .result:has(a.header[aria-current]:not([aria-current="false"])) { + background: var(--slate-50); + } + + .results .result:has(a.header[aria-current]:not([aria-current="false"])) ol a { + border-left-color: var(--slate-200); + + } + + .results .result .header .title { + font-size: var(--font-size-md); + line-height: var(--line-height-md); + margin: 0; + font-weight: 400; + letter-spacing: 0.025em; + } + + .results .result .header .details { + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + margin: 0; + } + + .results .result .sections { + position: relative; + } + + .results .result .sections .highlight { + position: absolute; + top: 0; + left: 0; + right: -1rem; + height: 0; + z-index: 10; + border-top-right-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; + background: var(--gray-100); + } + + .results .result:has(a.header:not([aria-current="true"])) .highlight { + display: none; + } + + .results .result .sections .overlight { + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 0; + z-index: 30; + background: var(--pink-600); + box-shadow: 0 0 5px var(--pink-400); + } + + .results .result:has(a.header:not([aria-current="true"])) .overlight { + display: none; + } + + .results .result .sections .section { + z-index: 20; + position: relative; + } + + .results .result .sections .section .excerpt { + font-size: var(--font-size-xs); + line-height: var(--line-height-xs); + border-left: 1px solid var(--slate-100); + padding-left: 1rem; + } + .results .result:has(a[aria-current="true"]) .sections .section .excerpt { + border-left: 1px solid var(--slate-200); + } + + .results .result .sections .section[data-depth="3"] .excerpt { padding-left: 2rem; } + .results .result .sections .section[data-depth="4"] .excerpt { padding-left: 3rem; } + .results .result .sections .section[data-depth="5"] .excerpt { padding-left: 4rem; } + .results .result .sections .section[data-depth="6"] .excerpt { padding-left: 5rem; } + + .results .result ol { + list-style: none; + margin: 0; + padding: 0; + } + .results .result ol a { + display: block; + border-left: 1px solid var(--slate-100); + padding: 0.3rem 0.5rem 0.3rem 0.5rem; + color: var(--pink-600); + text-decoration: none; + font-size: var(--font-size-md); + line-height: var(--line-height-sm); + letter-spacing: -0.025em; + } + .results .result ol a:hover { + text-decoration: underline; + } + .results .result ol a.depth-2 { padding-left: 1rem; } + .results .result ol a.depth-3 { padding-left: 2rem; } + .results .result ol a.depth-4 { padding-left: 3rem; } + .results .result ol a.depth-5 { padding-left: 4rem; } + .results .result ol a.depth-6 { padding-left: 5rem; } + + .results .result .section:has(> a[aria-expanded="false"]) .subsections { + display: none; + } + `; +} + +customElements.define("app-root", AppRoot); + + +/** + * @param {string} slug + * @param {Issue[]} issues + * @returns {import("lit-html").TemplateResult[]} + */ +function issuesToHtml(slug, issues) { + return issues.map(issue => { + const anchorId = `issue-${issue.id}`; + /** @type {(event:Event) => void} */ + const click = event => { + event.preventDefault(); + event.stopPropagation(); + event.target?.dispatchEvent(new ResultClickEvent(slug, anchorId)); + }; + + return html` +
  • + ${issue.id} + ${issue.title} +
  • + `; + }); +} + +/** + * @param {String} slug + * @param {Section[]} sections + * @param {Boolean} renderIssues + * @returns {import("lit-html").TemplateResult[]} + */ +function sectionsToHtml(slug, sections, renderIssues) { + if (!sections) return []; + return sections.map(section => { + /** @type {(event:MouseEvent) => void} */ + const onClick = event => { + event.stopPropagation(); + event.preventDefault(); + event.target?.dispatchEvent(new ResultClickEvent(slug, section.id)); + }; + + return html` +
  • + + ${unsafeHTML(section.title)} + + ${section.excerpt + ? html`
    ${unsafeHTML(section.excerpt)}
    ` + : ""} + + ${renderIssues && section.issues + ? html`
      ${issuesToHtml(slug, section.issues)}
    ` + : ""} + +
      + ${sectionsToHtml(slug, section.subsections, renderIssues)} +
    +
  • + `; + }); +} + +// /** +// * Like Element.closest, but returns all matching ancestors, not just the +// * closest. +// * +// * @param {Element} element The element to start the search +// * @param {string} selector Returned elements must match this CSS selector +// * @returns {Element[]} +// */ +// function ancestors(element, selector) { +// const results = []; + +// /** @type {Element|null|undefined} */ +// let currentElement = element.parentElement?.closest(selector); + +// while (currentElement) { +// results.push(currentElement); +// currentElement = currentElement.parentElement?.closest(selector); +// } + +// return results; +// } diff --git a/src/client/components/rpr-contribution.js b/src/client/components/rpr-contribution.js new file mode 100644 index 0000000..9a2b51d --- /dev/null +++ b/src/client/components/rpr-contribution.js @@ -0,0 +1,142 @@ +import { LitElement, html, css } from "lit" + +class Contribution extends LitElement { + static properties = { + name: { type: String, reflect: true }, + initials: { type: String, reflect: true }, + avatar: { type: String, reflect: true }, + filterable: { type: Boolean, reflect: true }, + }; + + constructor() { + super(); + this.initials = ""; + this.name = ""; + this.avatar = ""; + this.filterable = false; + } + + static styles = css` + :host(*) { + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + position: relative; + margin-top: 4rem; + display: block; + } + :host(:first) { + margin-top: 0; + } + + .avatar { + border: 1px dashed var(--slate-300); + width: 2rem; + height: 2rem; + border-radius: 9999px; + display: inline-block; + float: left; + margin-right: 1rem; + margin-bottom: 0; + overflow: hidden; + position: absolute; + left: -4rem; + top: -0rem; + pointer-events: none; + } + .avatar .some { + display: block; + width: 9999px; + } + .avatar .none { + display: table-cell; + width: 2rem; + height: 2rem; + text-align: center; + vertical-align: middle; + color: var(--slate-400); + } + + .avatar .some img { + height: 2rem; + } + + .avatar:has(.some) { + border: 1px solid transparent; + } + @media (max-width: 1485px) { + .avatar { + left: 0; + position: relative; + } + } + + .byline { + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + margin-bottom: 1rem; + display: block; + letter-spacing: -0.025em; + color: var(--slate-950); + } + + .byline rpr-filter { + border: 1px solid #E2E8F0; + border-radius: 1rem; + padding: 0.25rem 0.75rem; + } + + .byline .no-pin { + padding: 0.25rem 0; + border: 1px solid transparent; + } + + .content { + display: block; + color: var(--slate-700); + font-size: var(--font-size-md); + line-height: var(--line-height-md); + } + + .content ::slotted(p) { + margin-top: 0; + margin-bottom: 1.5rem; + } + .content ::slotted(p:last-child) { + margin-bottom: 0 !important; + } + .content > blockquote { + padding-left: 1rem; + font-size: 0.75rem; + line-height: 1rem; + } + `; + + render() { + return html` +
    + ${this.avatar + ? html`
    ${this.name}
    ` + : html`
    ${this.initials}
    ` + } +
    + +
    + +
    + `; + } + +} + +customElements.define("rpr-contribution", Contribution); diff --git a/src/client/components/rpr-filter.js b/src/client/components/rpr-filter.js new file mode 100644 index 0000000..fab11bc --- /dev/null +++ b/src/client/components/rpr-filter.js @@ -0,0 +1,145 @@ +import { LitElement, css, html } from "lit"; +import { activeFiltersContext, availableFiltersContext } from "./app-root.js"; +import { ContextConsumer } from "@lit/context"; + +export class Filter extends LitElement { + static properties = { + key: { type: String, reflect: true }, + value: { type: String, reflect: true }, + hideKey: { type: Boolean, reflect: true }, + hideCount: { type: Boolean, reflect: true }, + active: { type: Boolean, reflect: true }, + count: { type: Number, reflect: true }, + pertinant: { type: Boolean, reflect: true }, + }; + + static styles = css` + :host(*) { + position: relative; + + display: inline-flex; + flex-direction: row; + + justify-content: left; + gap: 0.5rem; + cursor: pointer; + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + } + .wrapper { + cursor: pointer; + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + } + .key { + color: rgb(148 163 184); + text-transform: capitalize; + font-size: var(--font-size-xs); + line-height: var(--line-height-xs); + } + .checkbox { + cursor: pointer; + margin: 0; + } + .value { + color: var(--slate-600); + cursor: pointer; + word-break: break-all; + } + .count { + color: var(--slate-400); + text-align: right; + } + `; + + _handleChange(event) { + this.active = event.target.checked; + this.dispatchEvent(new FilterClickEvent(this.key, this.value, this.active)); + } + + render() { + const filterId = `${this.key}/${this.value}`; + const activeFilters = this.activeFilters.value || []; + const active = activeFilters.some(f => f.key === this.key && f.value === this.value); + + this.count = (() => { + if (!this.availableFilters.value) return this.count; + const filters = this.availableFilters.value; + if (!filters.hasOwnProperty(this.key)) return 0; + if (!filters[this.key].hasOwnProperty(this.value)) return 0; + return filters[this.key][this.value]; + })(); + + this.active = active; + this.pertinant = this.count > 0 || active; + + return html` + + `; + } + + /** + * @param {string} key + * @param {string} value + * @param {Number} count + * @param {Boolean} hideKey + * @param {Boolean} hideCount + */ + constructor(key, value, count, hideKey = false, hideCount = false) { + super(); + + this.key = key; + this.value = value; + this.count = count; + this.hideKey = hideKey; + this.hideCount = hideCount; + + this.activeFilters = new ContextConsumer(this, { + context: activeFiltersContext, + subscribe: true, + }); + + this.availableFilters = new ContextConsumer(this, { + context: availableFiltersContext, + subscribe: true, + }); + } +} + +customElements.define("rpr-filter", Filter); + +export class FilterClickEvent extends Event { + /** + * @param {string} key + * @param {string} value + * @param {Boolean|null} force + */ + constructor(key, value, force = null) { + super("filter-click", { bubbles: true, composed: true }); + this.key = key; + this.value = value; + this.force = force; + } +} + diff --git a/src/client/components/rpr-issue.js b/src/client/components/rpr-issue.js new file mode 100644 index 0000000..abee6cb --- /dev/null +++ b/src/client/components/rpr-issue.js @@ -0,0 +1,80 @@ +import { LitElement, html, css } from "lit" +import { unsafeHTML } from "lit/directives/unsafe-html.js"; + +class Issue extends LitElement { + + static properties = { + issueId: { type: Number, reflect: true }, + issueTitle: { type: String, reflect: true }, + }; + + static styles = css` + #issue { + z-index: 10; + display: block; + transition: all; + transition-duration: 100ms; + margin: 1rem; + margin-right: -4rem; + padding: 1rem; + border-radius: 0.375rem; + background: linear-gradient(170deg, var(--yellow-100) 30%, var(--amber-200) 100%); + float: right; + clear: right; + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + position: relative; + text-decoration: none; + vertical-align: top; + width: 25%; + color: var(--yellow-700); + } + #issue:hover { + transform: translate(0, 0.25rem); + box-shadow: 0 25px 50px -12px rgba(202, 138, 4, 0.4); + background: linear-gradient(135deg, var(--yellow-100) 70%, var(--amber-200) 100%); + } + @media (max-width: 1485px) { + #issue { + margin-right: 0; + } + } + #heading { + color: var(--yellow-700); + font-weight: 700; + margin-right: 0.125rem; + letter-spacing: -0.05em; + } + #heading img { + height: 1rem; + width: 1rem; + display: inline-block; + position: relative; + top: 2px; + margin-right: 0.125rem; + display: none; + } + #title { + } + `; + + constructor() { + super(); + this.issueId = 0; + this.issueTitle = ""; + this.issueUrl = ""; + } + + render() { + this.id = `issue-${this.issueId}`; + + return html` + + #${this.issueId} + ${unsafeHTML(this.issueTitle)} → + + `; + } +} + +customElements.define("rpr-issue", Issue); diff --git a/src/client/components/rpr-sidenote.js b/src/client/components/rpr-sidenote.js new file mode 100644 index 0000000..84b7e5f --- /dev/null +++ b/src/client/components/rpr-sidenote.js @@ -0,0 +1,76 @@ +import { LitElement, html, css } from "lit"; + +class Sidenode extends LitElement { + constructor() { + super(); + } + + static styles = css` + label { + counter-increment: sidenote; + font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; + } + label::after { + content: counter(sidenote); + top: -0.25rem; + left: 0; + vertical-align: baseline; + font-size: 0.875rem; + line-height: 1.25rem; + position: relative; + background: white; + border-radius: 0.375rem; + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + color: rgb(75, 85, 99); + padding: 0.25rem 0.5rem; + } + #sidenote { + z-index: 10; + display: block; + background: white; + background: linear-gradient(170deg, var(--gray-50) 10%, var(--slate-50) 100%); + box-shadow: 1px 1px 2px rgba(0,0,0,0.1); + font-size: 0.875rem; + line-height: 1.25rem; + position: relative; + line-height: 1.25rem; + vertical-align: middle; + padding: 1rem; + margin: 1rem; + margin-right: -4rem; + float: right; + clear: right; + border-radius: 0.25rem; + width: 25%; + } + @media (max-width: 1485px) { + #sidenote { + margin-right: 0; + } + } + #sidenote::before { + content: counter(sidenote) "."; + float: left; + margin-right: 0.25rem; + color: rgb(107, 114, 128); + font-size: 0.875rem; + line-height: 1.25rem; + } + #sidenote ::slotted(img) { + padding: 0.5rem 0; + max-width: 100%; + } + #sidenote img:last-child { + padding-bottom: 0; + } + `; + + render() { + return html` + + `; + } + +} + +customElements.define("rpr-sidenote", Sidenode); diff --git a/web/static/components/rpr-timecode.js b/src/client/components/rpr-timecode.js similarity index 100% rename from web/static/components/rpr-timecode.js rename to src/client/components/rpr-timecode.js diff --git a/src/client/events.js b/src/client/events.js new file mode 100644 index 0000000..c69a932 --- /dev/null +++ b/src/client/events.js @@ -0,0 +1,30 @@ +export class ResultClickEvent extends Event { + /** @type {string} */ + slug + + /** @type {string|null} */ + section + + /** + * @param {string} slug + * @param {string|null} section + */ + constructor(slug, section = null) { + super("result-click", { bubbles: true, composed: true }); + this.slug = slug; + this.section = section; + } +} + +export class SearchChangeEvent extends Event { + /** @type {string} */ + query + + /** + * @param {string} query + */ + constructor(query) { + super("search-change", { bubbles: true, composed: true }); + this.query = query; + } +} diff --git a/src/client/utils.js b/src/client/utils.js new file mode 100644 index 0000000..79ac78d --- /dev/null +++ b/src/client/utils.js @@ -0,0 +1,115 @@ +/** + * Is any part of [element] visible in the viewport. [margin] expands the area + * considered visible in pixel units. + * + * @param {Element} element + * @param {number} margin + * @returns {Boolean} + */ +export function isVisible(element, margin = 0) { + const rect = element.getBoundingClientRect(); + + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + const viewportwidth = window.innerWidth || document.documentElement.clientWidth; + + // Visual Reference: + // - https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect#return_value + + if (rect.bottom < margin) return false; + if (rect.top > viewportHeight + margin) return false; + if (rect.left > viewportwidth + margin) return false; + if (rect.right < margin) return false; + return true; +} + + +/** + * @param {Result} a + * @param {Result} b + * @returns {number} + */ +export function resultsByScore(a, b) { + return (b.score || 0) - (a.score || 0); +} + +/** + * @param {Result[]} localResults + * @param {PagefindResult[]} pagefindResults + * @param {string[]} pagefindResuldIdList + * @returns {Promise} The new results shape + */ +export async function loadResultData(localResults, pagefindResults, pagefindResuldIdList) { + const results = structuredClone(localResults); + + const promises = pagefindResuldIdList.map(async id => { + const localIndex = results.findIndex(result => result.pagefindResultId === id); + const localResult = results[localIndex]; + if (!localResult) return; + if (localResult.loaded) return; + + const pagefindIndex = pagefindResults.findIndex(pagefindResult => { + return pagefindResult.id === id; + }); + + if (pagefindIndex === -1) return; + + /** @type {PagefindResult} */ + const pagefindResult = pagefindResults[pagefindIndex]; + + + const data = await pagefindResult.data(); + + const preludeSubResult = data?.sub_results.find(s => !s.hasOwnProperty("anchor")); + localResult.excerpt = preludeSubResult?.excerpt || ""; + + walkSections(localResult.sections, section => { + const subResult = data?.sub_results.find(s => (s.anchor?.id || "") === section.id); + section.excerpt = subResult?.excerpt || ""; + }); + localResult.loaded = true; + }); + + await Promise.all([promises]); + return results; +}; + +/** + * @param {Section[]} sections + * @param {(section:Section) => void} callback + */ +function walkSections(sections, callback) { + if (!sections) return; + for (const section of sections) { + callback(section); + walkSections(section.subsections, callback); + } +} + +/** + * @param {Filter[]} filterArr + * @returns {Object.} + */ +export function pagefindFilters(filterArr) { + const filters = {}; + filterArr.forEach(filter => { + if (filters.hasOwnProperty(filter.key)) { + filters[filter.key].push(filter.value); + } else { + filters[filter.key] = [filter.value]; + } + }); + return filters; +} + +/** + * @param {Section[]} sections + * @param {string} sectionId + * @returns {Section|undefined} + */ +export function findSection(sections, sectionId) { + if (sectionId === "") return undefined; + for (const section of sections) { + if (section.id === sectionId) return section; + return findSection(section.subsections, sectionId); + } +} diff --git a/src/filter-test.js b/src/filter-test.js new file mode 100644 index 0000000..f5e2b6c --- /dev/null +++ b/src/filter-test.js @@ -0,0 +1,13 @@ +import { extractPagefindFilters } from "./utils.js"; +import fs from "fs"; + + +const html = fs.readFileSync(process.argv[2], "utf8"); +console.log(extractPagefindFilters(html)); + +console.log(extractPagefindFilters(` + + valueContent + valueContent + valueContent +`)); diff --git a/src/parser/contributors.js b/src/parser/contributors.js new file mode 100644 index 0000000..c79a26e --- /dev/null +++ b/src/parser/contributors.js @@ -0,0 +1,69 @@ +import path from "path"; + +/** + * @param {Context} context + * @returns {import("marked").TokenizerAndRendererExtension} + */ +export default ({ asset: parsed, avatars }) => ({ + name: "contribution", + level: "block", + + start: src => src.match(/^[a-zA-Z0-9]+: /)?.index, + + tokenizer(src, _tokens) { + const rule = /^([a-zA-Z0-9]+): /; + const match = rule.exec(src); + if (match) { + const initials = match[1]; + const token = { + type: "contribution", + raw: match[0], + initials, + }; + + const contributors = parsed.frontMatter.speakers; + + const contributorMapExists = typeof contributors !== "undefined"; + const speakerExists = contributors.hasOwnProperty(token.initials); + + if (!contributorMapExists || !speakerExists) { + parsed.errors.push(`Cannot find "speakers.${token.initials}" in front matter.`); + return; + } + + let name = contributors[token.initials]; + token.filterable = true; + if (name.startsWith("-")) { + name = name.substring(1); + token.filterable = false; + } + token.name = name; + + const slug = name.toLowerCase().replaceAll(" ", "-"); + + const avatar = avatars.find(avatar => path.parse(avatar).name === slug); + token.avatar = avatar ? `/avatars/${avatar}` : ""; + + if (!parsed.contributors.some(contributor => contributor.name === name)) { + parsed.contributors.push({ + name, + filterable: token.filterable, + initials: token.initials, + avatar: token.avatar, + }); + } + return token; + } + }, + + renderer({ name, initials, avatar, filterable }) { + return ` + + `; + }, +}); diff --git a/src/parser/fetcher.js b/src/parser/fetcher.js new file mode 100644 index 0000000..1092675 --- /dev/null +++ b/src/parser/fetcher.js @@ -0,0 +1,59 @@ +/** + * @typedef {object} FetcherResponse + * @param {string} url + * @param {Promise} response + */ + +/** + * @typedef {Object.>} FetcherCache + */ + +export class Fetcher { + /** @type {FetcherResponse[]} */ + #responses = [] + + /** @type [url:string, key:string, value:string][] */ + #knownValues = [] + + /** @type [url:string, key:string, value:string][] */ + #unknownValues = [] + + /** @type [url:string, key:string][] */ + #seen = [] + + /** @param {FetcherCache} cache */ + constructor(cache) { + for (const [url, keys] of Object.entries(cache)) { + for (const [key, value] of Object.entries(keys)) { + this.#knownValues.push([url, key, value]); + } + } + } + + /** + * @param {string} url + * @param {string} key + * @param {(response:Response) => Promise} handler + * @returns Promise + */ + async fetch(url, key, handler) { + this.#seen.push([url, key]); + + let entry = this.#knownValues.find(e => e[0] === url && e[1] === key); + if (entry) { + return entry[2]; + } + + /** @type {FetcherResponse} */ + let response = this.#responses.find(r => r.url === url); + + if (!response) { + response = { url, response: fetch(url) }; + this.#responses.push(response); + } + + const value = await handler(await response.response); + this.#unknownValues.push([url, key, value]); + return value; + } +} diff --git a/src/parser/index.js b/src/parser/index.js new file mode 100644 index 0000000..15f3c1d --- /dev/null +++ b/src/parser/index.js @@ -0,0 +1,75 @@ +import fs from "fs"; +import path from "path"; +import { parse } from "./parser.js"; +import { createAssetStub } from "../utils.js"; + +const OUTPUT_EXT = ".json"; + +const inputs = process.argv.slice(2); +let toStdout = false; + +if (inputs.length === 0 || inputs[0] === "--help") { + console.log(`Usage: parser file.md +Usage: parser file1.md file2.md file3.md ./out/directory +Usage: parser file*.md ./out/directory`); + process.exit(1); +} + +/** @type {string[]} */ +const inputFilenameList = []; + +/** @type {string[]} */ +const outputFilenameList = []; + +if (inputs.length >= 1) { + if (!fs.lstatSync(inputs[0]).isFile()) { + console.error("First argument must be a file."); + process.exit(1); + } +} + +if (inputs.length === 1) { + inputFilenameList.push(inputs[0]); + toStdout = true; +} else { + const outputDirname = inputs[inputs.length - 1]; + inputs + .slice(0, inputs.length - 1) + .forEach(input => { + inputFilenameList.push(input); + const outputFilename = path.join(outputDirname, `${path.parse(input).name}${OUTPUT_EXT}`); + outputFilenameList.push(outputFilename); + }); +} + +const start = process.hrtime(); + +/** @type {Promise[]} */ +const inputAssetList = inputFilenameList.map(async inputFilename => createAssetStub(inputFilename)); + +const assetList = parse(inputAssetList); + +const nullresults = assetList.map((assetPromise, i) => new Promise(async resolve => { + const asset = await assetPromise; + const json = JSON.stringify(asset); + + if (toStdout) { + console.log(json); + return resolve(null); + } + + const outputFilename = outputFilenameList[i]; + const parentDir = path.parse(outputFilename).dir; + fs.mkdirSync(parentDir, { recursive: true }); + fs.writeFileSync(outputFilename, json); + + console.log(outputFilename) + resolve(null); +})); + +await Promise.all(nullresults); +if (!toStdout) { + const diff = process.hrtime(start); + const ms = Math.ceil(diff[1] * 1e-6); + console.log(`Completed in ${ms}ms.`); +} diff --git a/src/parser/mentions.js b/src/parser/mentions.js new file mode 100644 index 0000000..57c9fde --- /dev/null +++ b/src/parser/mentions.js @@ -0,0 +1,32 @@ +/** + * @param {Context} _context + * @returns {import("marked").TokenizerAndRendererExtension} + */ +export default (_context) => ({ + name: "mention", + level: "inline", + + start(src) { + return src.match(/\[\[/)?.index; + }, + + tokenizer(src, _tokens) { + const rule = /^\[\[(.*?)(\|.*?)?]\]/; + const match = rule.exec(src); + if (match) { + const signature = match[1].trim(); + const label = match[2]?.substring(1) || signature; + const token = { + type: "mention", + raw: match[0], + signature, + tokens: this.lexer.inlineTokens(label.trim()), + } + return token; + } + }, + + renderer(token) { + return this.parser.parseInline(token.tokens || []); + }, +}); diff --git a/src/parser/pagefind-meta.js b/src/parser/pagefind-meta.js new file mode 100644 index 0000000..04b7d1d --- /dev/null +++ b/src/parser/pagefind-meta.js @@ -0,0 +1,10 @@ +/** @param {Context} context */ +export default function(context) { + + /** @param {import("marked").Token} token */ + return token => { + if (context.asset.slug === "tribute-to-dr-raymond-peat") { + console.log(token.type, token); + } + }; +} diff --git a/src/parser/parser.js b/src/parser/parser.js new file mode 100644 index 0000000..694dc19 --- /dev/null +++ b/src/parser/parser.js @@ -0,0 +1,50 @@ +import fs from "fs"; +import { Marked } from "marked"; +import timecodes from "./timecodes.js"; +import sections from "./sections.js"; +import contributors from "./contributors.js"; +import sidenotes from "./sidenotes.js"; +import mentions from "./mentions.js"; +import { Fetcher } from "./fetcher.js"; +import yaml from "js-yaml"; + +/** + * @param {Promise[]} assetPromiseList + * @returns {Promise[]} + */ +export function parse(assetPromiseList) { + const cacheYaml = fs.readFileSync("assets/data/cache.yml", "utf8"); + const cacheData = /** @type {import("./fetcher.js").FetcherCache} */ (yaml.load(cacheYaml, {})); + const fetcher = new Fetcher(cacheData); + const avatars = fs.readdirSync("./src/public/avatars"); + + return assetPromiseList.map(async inputAssetPromise => { + const asset = await inputAssetPromise; + if (asset.html || !asset.markdown) return asset; + + /** @type {Context} */ + const context = { fetcher, asset, avatars }; + + const marked = new Marked({ + extensions: [ + timecodes(context), + mentions(context), + sidenotes(context), + contributors(context), + sections(context), + ], + async: true, + walkTokens: async token => { + const asyncToken = /** @type {AsyncToken} */ (token); + if (asyncToken.resolveAsync) await asyncToken.resolveAsync(token, context); + } + }); + + asset.html = await marked.parse(asset.markdown); + + return asset; + }); +} + +export default { parse }; + diff --git a/src/parser/sections.js b/src/parser/sections.js new file mode 100644 index 0000000..1162488 --- /dev/null +++ b/src/parser/sections.js @@ -0,0 +1,76 @@ +import { walkTokens } from "marked"; +import slugify from "slugify"; +import he from "he"; +import { findLastSection, findSectionParent } from "./utils.js"; + +const prefixRegex = /^(([0-9]+.)+) /; +const slugPurgeRegex = /['"()!:@.~+*`]/g; + +/** + * @param {Context} _context + * @returns {import("marked").RendererExtension} + */ +export default ({ asset }) => ({ + name: "heading", + renderer: ({ depth, raw, tokens }) => { + const tag = `h${depth}`; + + if (depth === 1) { + asset.errors.push(`Level one heading "${raw}" found. The level one heading is reserved for the page title. Consider using heading levels 2 through 6.`); + return; + } + + let [title, timecode] = ["", ""]; + walkTokens(tokens || [], t => { + if (t.type === "text") { + title += t.text; + } else if (t.type === "timecode") { + timecode = t.timecode; + } + }); + title = title.trim(); + + const id = slugify(he.decode(title), { + lower: true, + trim: true, + remove: slugPurgeRegex, + }); + + let prefix = ""; + const prefixMatch = title.match(prefixRegex); + + if (prefixMatch) { + prefix = prefixMatch[1]; + title = title.substring(prefixMatch[0].length); + } + + const sectionParent = findSectionParent(asset, depth); + + if (!sectionParent) { + const lastSection = findLastSection(asset); + if (!lastSection) { + asset.errors.push(`The first heading "${raw}" is a level ${depth} heading, but must be a level 2 heading.`); + } else { + asset.errors.push(`Level ${depth} heading "${raw}" follows a level ${lastSection.depth} heading. Must be a level ${lastSection.depth - 1} heading or less.`); + } + return; + } + + sectionParent.push({ + depth, title, id, prefix, timecode, + issues: [], + subsections: [], + excerpt: null, + }); + + return ` + <${tag} id="${id}">${title} + `; + }, +}); diff --git a/src/parser/sidenotes.js b/src/parser/sidenotes.js new file mode 100644 index 0000000..349feda --- /dev/null +++ b/src/parser/sidenotes.js @@ -0,0 +1,81 @@ +import { JSDOM } from "jsdom"; +import he from "he"; +import { lastSection } from "./utils.js"; + +/** + * @param {Context} context + * @returns {import("marked").TokenizerAndRendererExtension} + */ +export default ({ fetcher, asset }) => ({ + name: "sidenote", + level: "inline", + + start(src) { + return src.match(/\{/)?.index; + }, + + tokenizer(src, _tokens) { + const rule = /^\{(.*?)\}/; + const match = rule.exec(src); + if (match) { + const text = match[1].trim(); + const issue = match[1].trim().match(/^\#([0-9]+)$/); + + if (issue) { + const id = issue[1]; + const url = `https://github.com/marcuswhybrow/ray-peat-rodeo/issues/${id}`; + + /** @type {AsyncToken} */ + const asyncToken = { + type: "sidenote", + raw: match[0], + issueId: id, + issueTitle: fetcher.fetch(url, "title", async response => { + const dom = new JSDOM(await response.text()); + const title = dom.window.document.querySelector("h1 bdi")?.textContent; + if (!title) asset.errors.push(`Failed to find title for issue "${id}".`); + return title || ""; + }), + resolveAsync: async token => { + token.issueTitle = await token.issueTitle; + } + }; + return asyncToken; + } else { + return { + type: "sidenote", + raw: match[0], + tokens: this.lexer.inlineTokens(text), + }; + } + } + }, + + renderer(token) { + if (token.issueId) { + + const section = asset.sections.length === 0 + ? null + : lastSection(asset.sections[asset.sections.length - 1]); + + /** @type {Issue} */ + const issue = { + id: parseInt(token.issueId), + title: token.issueTitle + }; + + if (section) { + section.issues.push(issue); + } else { + asset.issues.push(issue); + } + + return ``; + } else { + return ` + ${this.parser.parseInline(token.tokens || [])} + `; + } + }, +}); + diff --git a/src/parser/timecodes.js b/src/parser/timecodes.js new file mode 100644 index 0000000..d29ec98 --- /dev/null +++ b/src/parser/timecodes.js @@ -0,0 +1,33 @@ +/** + * @param {Context} _context + * @returns {import("marked").TokenizerAndRendererExtension} + */ +export default (_context) => ({ + name: "timecode", + level: "inline", + + start: src => src.match(/\[[0-9]+/)?.index, + + tokenizer(src, _tokens) { + const rule = /^\[([0-9]+\:)?([0-9]{1,2})\:([0-9]{1,2})\]/; + const match = rule.exec(src); + if (match) { + const hours = match[1]?.substring(0, match[1].length - 1)?.padStart(2, "0") || "00"; + const minutes = match[2].padStart(2, "0"); + const seconds = match[3].padStart(2, "0"); + return { + type: "timecode", + raw: match[0], + timecode: `${hours}:${minutes}:${seconds}`, + }; + } + }, + + renderer({ timecode }) { + return ` + + `; + }, +}); diff --git a/src/parser/utils.js b/src/parser/utils.js new file mode 100644 index 0000000..5f55a4d --- /dev/null +++ b/src/parser/utils.js @@ -0,0 +1,35 @@ +/** + * @param {Section} section + * @returns {Section} + */ +export function lastSection(section) { + if (section.subsections.length === 0) return section; + return lastSection(section.subsections[section.subsections.length - 1]); +} + +/** + * @param {Asset} parsed + * @returns {Section|undefined} + */ +export function findLastSection(parsed) { + if (parsed.sections.length === 0) return undefined; + let section = parsed.sections[parsed.sections.length - 1]; + while (section.subsections.length > 0) { + section = section.subsections[section.subsections.length - 1]; + } + return section; +} + +/** + * @param {Asset} parsed + * @param {number} depth + * @return {Section[]|undefined} + */ +export function findSectionParent(parsed, depth) { + let sections = parsed.sections; + for (let currentDepth = 2; currentDepth < depth; currentDepth++) { + if (sections.length === 0) return undefined; + sections = sections[sections.length - 1].subsections; + } + return sections; +} diff --git a/web/static/images/avatars/andrew-murray.webp b/src/public/avatars/andrew-murray.webp similarity index 100% rename from web/static/images/avatars/andrew-murray.webp rename to src/public/avatars/andrew-murray.webp diff --git a/web/static/images/avatars/danny-roddy.jpg b/src/public/avatars/danny-roddy.jpg similarity index 100% rename from web/static/images/avatars/danny-roddy.jpg rename to src/public/avatars/danny-roddy.jpg diff --git a/web/static/images/avatars/david-butterworth.jpg b/src/public/avatars/david-butterworth.jpg similarity index 100% rename from web/static/images/avatars/david-butterworth.jpg rename to src/public/avatars/david-butterworth.jpg diff --git a/web/static/images/avatars/gary-null.webp b/src/public/avatars/gary-null.webp similarity index 100% rename from web/static/images/avatars/gary-null.webp rename to src/public/avatars/gary-null.webp diff --git a/web/static/images/avatars/karen-mcc.webp b/src/public/avatars/karen-mcc.webp similarity index 100% rename from web/static/images/avatars/karen-mcc.webp rename to src/public/avatars/karen-mcc.webp diff --git a/web/static/images/avatars/male.png b/src/public/avatars/male.png similarity index 100% rename from web/static/images/avatars/male.png rename to src/public/avatars/male.png diff --git a/web/static/images/avatars/marcus-whybrow.jpg b/src/public/avatars/marcus-whybrow.jpg similarity index 100% rename from web/static/images/avatars/marcus-whybrow.jpg rename to src/public/avatars/marcus-whybrow.jpg diff --git a/web/static/images/avatars/nicole-behnam.webp b/src/public/avatars/nicole-behnam.webp similarity index 100% rename from web/static/images/avatars/nicole-behnam.webp rename to src/public/avatars/nicole-behnam.webp diff --git a/web/static/images/avatars/ray-peat.jpg b/src/public/avatars/ray-peat.jpg similarity index 100% rename from web/static/images/avatars/ray-peat.jpg rename to src/public/avatars/ray-peat.jpg diff --git a/web/static/images/avatars/sarah-johannesen-murray.webp b/src/public/avatars/sarah-johannesen-murray.webp similarity index 100% rename from web/static/images/avatars/sarah-johannesen-murray.webp rename to src/public/avatars/sarah-johannesen-murray.webp diff --git a/web/static/docs/ray-peat-rodeo-banner.png b/src/public/docs/ray-peat-rodeo-banner.png similarity index 100% rename from web/static/docs/ray-peat-rodeo-banner.png rename to src/public/docs/ray-peat-rodeo-banner.png diff --git a/web/static/images/favicons/android-chrome-192x192.png b/src/public/favicons/android-chrome-192x192.png similarity index 100% rename from web/static/images/favicons/android-chrome-192x192.png rename to src/public/favicons/android-chrome-192x192.png diff --git a/web/static/images/favicons/android-chrome-512x512.png b/src/public/favicons/android-chrome-512x512.png similarity index 100% rename from web/static/images/favicons/android-chrome-512x512.png rename to src/public/favicons/android-chrome-512x512.png diff --git a/web/static/images/favicons/apple-touch-icon.png b/src/public/favicons/apple-touch-icon.png similarity index 100% rename from web/static/images/favicons/apple-touch-icon.png rename to src/public/favicons/apple-touch-icon.png diff --git a/web/static/images/favicons/favicon-16x16.png b/src/public/favicons/favicon-16x16.png similarity index 100% rename from web/static/images/favicons/favicon-16x16.png rename to src/public/favicons/favicon-16x16.png diff --git a/web/static/images/favicons/favicon-32x32.png b/src/public/favicons/favicon-32x32.png similarity index 100% rename from web/static/images/favicons/favicon-32x32.png rename to src/public/favicons/favicon-32x32.png diff --git a/web/static/images/favicons/favicon.ico b/src/public/favicons/favicon.ico similarity index 100% rename from web/static/images/favicons/favicon.ico rename to src/public/favicons/favicon.ico diff --git a/web/static/images/favicons/site.webmanifest b/src/public/favicons/site.webmanifest similarity index 100% rename from web/static/images/favicons/site.webmanifest rename to src/public/favicons/site.webmanifest diff --git a/web/static/global.css b/src/public/global.css similarity index 50% rename from web/static/global.css rename to src/public/global.css index cfb2eac..30b00f8 100644 --- a/web/static/global.css +++ b/src/public/global.css @@ -265,4 +265,452 @@ --slate-200: rgb(226, 232, 240); --slate-100: rgb(241, 245, 249); --slate-50: rgb(248, 250, 252); + + /* Tailwind Text Sizes */ + + --font-size-xs: 0.75rem; + --line-height-xs: 1rem; + + --font-size-sm: 0.875rem; + --line-height-sm: 1.25rem; + + --font-size-md: 1rem; + --line-height-md: 1.5rem; + + --font-size-lg: 1.125rem; + --line-height-lg: 1.75rem; + + --font-size-xl: 1.25rem; + --line-height-xl: 1.75rem; + + --font-size-2xl: 1.5rem; + --line-height-2xl: 2rem; + + --font-size-3xl: 1.875rem; + --line-height-3xl: 2.25rem; + + --font-size-4xl: 2.25rem; + --line-height-4xl: 2.5rem; + + --font-size-5xl: 3rem; + --line-height-5xl: 1; + + --font-size-6xl: 3.75rem; + --line-height-6xl: 1; + + --font-size-7xl: 4.5rem; + --line-height-7xl: 1; + + --font-size-8xl: 6rem; + --line-height-8xl: 1; + + --font-size-9xl: 8rem; + --line-height-9xl: 1; +} + +body app-root { + margin: 0 auto; +} + +.main { + margin-left: 8rem; +} + +article mark { + background-color: #fef08a +} + +article p { + margin-bottom: 16px; +} + +article blockquote { + padding-left: 16px; + border-left: 2px solid lightgray; + font-size: 1.1rem; + font-style: italic; + line-height: 2rem; + margin: 24px 0; +} + +article a { + text-decoration: none; + color: var(--pink-600); +} + +article a:hover { + text-decoration: underline; +} + +article { + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-size: var(--font-size-md); + line-height: var(--line-height-md); + padding-top: 4rem; + +} + +article h1 { + font-weight: 700; + font-size: var(--font-size-4xl); + line-height: var(--line-height-4xl); + + letter-spacing: -0.025em; + margin-top: 0; +} + +article h1 a { + color: var(--slate-950); + text-decoration: none; +} + +article h1 a:hover { + text-decoration: underline; +} + +article h2 { + margin: 0; + font-size: var(--font-size-3xl); + line-height: var(--line-height-3xl); + font-weight: 400; + color: var(--slate-800); + letter-spacing: -0.05em; + border-bottom: 1px solid var(--slate-200); + padding-bottom: 1rem; +} + +article #details a { + color: var(--pink-600); + letter-spacing: -0.05em; + text-decoration: none; + margin-right: 1rem; + border-radius: 9999px; + border: 1px solid var(--slate-200); + padding: 0.5rem 1rem; + position: relative; + left: -0.5rem; +} + +article #details a:hover { + text-decoration: underline; + color: var(--pink-700); +} + +article footer { + border-top: 1px solid #eee; + border-bottom: 1px solid #eee; + margin: 8rem 0 4rem; +} + +article footer table { + width: 100%; +} + +article footer table td { + border-bottom: 1px solid #eee; + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + padding: 1rem 0; + color: #78716C; + vertical-align: top; +} + +article footer table td:first-child { + width: 25%; + text-align: left; +} + +article footer table tr:last-child td { + border-bottom: none; +} + +article footer table ul { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +article footer a { + color: #78716C; + word-break: break-all; +} + +article footer a:visited { + color: #57534E; +} + +article footer .legal a { + color: var(--pink-600); +} + +article footer .legal a:hover { + color: var(--pink-600); +} + +article header { + margin-bottom: 2rem; +} + +article rpr-filter, +article rpr-contribution::part(filter) { + border: 0; + padding: 0; +} + +article rpr-filter::part(wrapper):hover, +article rpr-contribution::part(filter-wrapper):hover { + border: 1px solid var(--slate-400); +} + +article rpr-filter::part(wrapper), +article rpr-contribution::part(filter-wrapper) { + border: 1px solid var(--slate-200); + border-radius: 9999px; + padding: 0.3rem 0.75rem; + + display: flex; + flex-direction: row; + align-items: center; + gap: 0.6rem; +} + +article rpr-timecode { + display: none; +} + +body { + margin: 0; + padding: 0; +} + +:target { + scroll-margin-top: 100px; +} + + +.results { + position: fixed; + top: 0; + left: calc(var(--sidebar) + 1px); + width: var(--sidebar); + height: 100vh; + overflow-y: scroll; + + border-right: 1px solid var(--slate-200); +} + +.results { + margin: 0; + padding: 0; + + display: flex; + flex-direction: column; + list-style: none; +} + +.results .result { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem 2rem; +} + +.results .result[data-score=""] { + display: none; +} + +.results .issues { + display: grid; + grid-template-columns: min-content auto; + column-gap: 0.5rem; + row-gap: 0.5rem; +} + +.results .issues .issue { + grid-column: 1 / -1; + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); +} + +.results .section .issues .issue { + border-left: 1px solid var(--amber-100); + padding-left: 1rem; +} + +.results .section .issues .issue:last-child { + padding-bottom: 1rem; +} + +.results .issues .id { + color: var(--amber-600); + font-weight: 700; + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + margin-right: 0.25rem; + letter-spacing: -0.025em; +} + +.results .issues .id:before { + content: "#"; +} + +.results .issues .title { + display: inline; + border: 0; + padding: 0; + color: var(--amber-600); + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); +} + +.results .result .excerpt:not(:empty) { + font-size: var(--font-size-xs); + line-height: var(--line-height-xs); + padding-bottom: 0.5rem; +} + +.results .result.hide:not(:has([aria-current="true"])) { + display: none; +} + +.results .result a.header { + display: flex; + flex-direction: column; + gap: 0.5rem; + color: var(--slate-800); + text-decoration: none; +} + +.results .result:has(a.header[aria-current]:not([aria-current="false"])) { + background: var(--slate-50); +} + +.results .result:has(a.header[aria-current]:not([aria-current="false"])) ol a { + border-left-color: var(--slate-200); + +} + +.results .result .header .title { + font-size: var(--font-size-md); + line-height: var(--line-height-md); + margin: 0; + font-weight: 400; + letter-spacing: 0.025em; +} + +.results .result .header .details { + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + margin: 0; +} + +.results .result .sections { + position: relative; +} + +.results .result .sections .highlight { + position: absolute; + top: 0; + left: 0; + right: -1rem; + height: 0; + z-index: 10; + border-top-right-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; + background: var(--gray-100); +} + +.results .result:has(a.header:not([aria-current="true"])) .highlight { + display: none; +} + +.results .result .sections .overlight { + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 0; + z-index: 30; + background: var(--pink-600); + box-shadow: 0 0 5px var(--pink-400); +} + +.results .result:has(a.header:not([aria-current="true"])) .overlight { + display: none; +} + +.results .result .sections .section { + z-index: 20; + position: relative; +} + +.results .result .sections .section .excerpt { + font-size: var(--font-size-xs); + line-height: var(--line-height-xs); + border-left: 1px solid var(--slate-100); + padding-left: 1rem; +} + +.results .result .sections .section[data-depth="3"] .excerpt { + padding-left: 2rem; +} + +.results .result .sections .section[data-depth="4"] .excerpt { + padding-left: 3rem; +} + +.results .result .sections .section[data-depth="5"] .excerpt { + padding-left: 4rem; +} + +.results .result .sections .section[data-depth="6"] .excerpt { + padding-left: 5rem; +} + +.results .result ol { + list-style: none; + margin: 0; + padding: 0; +} + +.results .result ol a { + display: block; + border-left: 1px solid var(--slate-100); + padding: 0.3rem 0.5rem 0.3rem 0.5rem; + color: var(--pink-600); + text-decoration: none; + font-size: var(--font-size-md); + line-height: var(--line-height-sm); + letter-spacing: -0.025em; +} + +.results .result ol a:hover { + text-decoration: underline; +} + +.results .result ol a.depth-2 { + padding-left: 1rem; +} + +.results .result ol a.depth-3 { + padding-left: 2rem; +} + +.results .result ol a.depth-4 { + padding-left: 3rem; +} + +.results .result ol a.depth-5 { + padding-left: 4rem; +} + +.results .result ol a.depth-6 { + padding-left: 5rem; +} + +.results .result .section:has(> a[aria-expanded="false"]) .subsections { + display: none; } diff --git a/web/static/images/2013-03-07-ray-peat-in-mirror.jpg b/src/public/images/2013-03-07-ray-peat-in-mirror.jpg similarity index 100% rename from web/static/images/2013-03-07-ray-peat-in-mirror.jpg rename to src/public/images/2013-03-07-ray-peat-in-mirror.jpg diff --git a/web/static/images/2013-03-07-ray-peat-standing.jpg b/src/public/images/2013-03-07-ray-peat-standing.jpg similarity index 100% rename from web/static/images/2013-03-07-ray-peat-standing.jpg rename to src/public/images/2013-03-07-ray-peat-standing.jpg diff --git a/web/static/images/2013-03-07-ray-peat-walking.jpg b/src/public/images/2013-03-07-ray-peat-walking.jpg similarity index 100% rename from web/static/images/2013-03-07-ray-peat-walking.jpg rename to src/public/images/2013-03-07-ray-peat-walking.jpg diff --git a/web/static/images/2020-04-26-einstein-quote.jfif b/src/public/images/2020-04-26-einstein-quote.jfif similarity index 100% rename from web/static/images/2020-04-26-einstein-quote.jfif rename to src/public/images/2020-04-26-einstein-quote.jfif diff --git a/web/static/images/branching-icon.svg b/src/public/images/branching-icon.svg similarity index 100% rename from web/static/images/branching-icon.svg rename to src/public/images/branching-icon.svg diff --git a/web/static/images/edit-round-icon.svg b/src/public/images/edit-round-icon.svg similarity index 100% rename from web/static/images/edit-round-icon.svg rename to src/public/images/edit-round-icon.svg diff --git a/web/static/images/exclamation-round-icon.svg b/src/public/images/exclamation-round-icon.svg similarity index 100% rename from web/static/images/exclamation-round-icon.svg rename to src/public/images/exclamation-round-icon.svg diff --git a/web/static/images/github-logo.png b/src/public/images/github-logo.png similarity index 100% rename from web/static/images/github-logo.png rename to src/public/images/github-logo.png diff --git a/web/static/images/github-mark.svg b/src/public/images/github-mark.svg similarity index 100% rename from web/static/images/github-mark.svg rename to src/public/images/github-mark.svg diff --git a/web/static/images/interface-layout-left-sidebar-icon.svg b/src/public/images/interface-layout-left-sidebar-icon.svg similarity index 100% rename from web/static/images/interface-layout-left-sidebar-icon.svg rename to src/public/images/interface-layout-left-sidebar-icon.svg diff --git a/web/static/images/magnifying-glass-icon.svg b/src/public/images/magnifying-glass-icon.svg similarity index 100% rename from web/static/images/magnifying-glass-icon.svg rename to src/public/images/magnifying-glass-icon.svg diff --git a/web/static/images/plus-line-icon.svg b/src/public/images/plus-line-icon.svg similarity index 100% rename from web/static/images/plus-line-icon.svg rename to src/public/images/plus-line-icon.svg diff --git a/web/static/images/ray-peat-sitting.jpg b/src/public/images/ray-peat-sitting.jpg similarity index 100% rename from web/static/images/ray-peat-sitting.jpg rename to src/public/images/ray-peat-sitting.jpg diff --git a/web/static/images/round-line-bottom-arrow-icon.svg b/src/public/images/round-line-bottom-arrow-icon.svg similarity index 100% rename from web/static/images/round-line-bottom-arrow-icon.svg rename to src/public/images/round-line-bottom-arrow-icon.svg diff --git a/web/static/images/star-full-icon.svg b/src/public/images/star-full-icon.svg similarity index 100% rename from web/static/images/star-full-icon.svg rename to src/public/images/star-full-icon.svg diff --git a/src/types/index.d.ts b/src/types/index.d.ts new file mode 100644 index 0000000..dae278c --- /dev/null +++ b/src/types/index.d.ts @@ -0,0 +1,253 @@ +import { Tokens } from "marked"; +import { Context } from "../parser/index.js"; +import { Fetcher } from "../parser/fetcher.js"; + +// Custom events +// +// Reference +// - https://stackoverflow.com/a/68783088 +// - https://github.com/microsoft/TypeScript/issues/28357#issuecomment-748550734 + + +declare global { + interface GlobalEventHandlersEventMap { + "state-changed": CustomEvent<{ + state: State + push: boolean | null + }>; + "filter-click": import("../static/components/rpr-filter.js").FilterClickEvent; + "result-click": import("../static/components/app-root.js").ResultClickEvent; + "search-change": import("../static/components/app-root.js").SearchChangeEvent; + "search-buffer": import("../static/components/app-root.js").SearchBufferEvent; + } + + type SearchBufferAction = "open-buffer" | "reset-and-open-buffer" | "flush-and-close-buffer"; + + type Filter = { + key: string, + value: string, + } + + type State = { + search: string; + filters: Filter[]; + path: string; + hash: string; + } + + type Issue = { + id: number; + title: string; + } + + type Section = { + title: string; + depth: number; + id: string; + prefix: string; + timecode: string; + issues: Issue[]; + excerpt: string | null; + subsections: Section[]; + } + + type Contributor = { + name: string; + filterable: boolean; + initials: string; + avatar: string; + } + + /** + * Data for a visual search result. + * + * This describes a markdown asset, but also includes search meta data. + */ + type Result = { + /** The asset title from the markdown frontmatter */ + title: string; + + /** The asset slug from the markdown file name */ + slug: string; + + /** The asset date from the markdonw file name */ + date: string; + + /** The name of the podcast, person, etc. that published this asset */ + publisher: string; + + /** The Pagefind result ID corresponding to this Result */ + pagefindResultId: string; + + /** True if the current Pagefind data has been loaded for this result */ + loaded: Boolean; + + /** + * Excerpt pertenant to the current search string that occurs before the + * first section heading. + * + * Each document section in [sections] has it's own excerpt. + */ + excerpt: string | null; + + /** + * Issues that are references before the first section heading. + * + * Issues are visual callouts in the rendered HTML of an asset that + * link to a GitHub issue tracking a potential improvement to the text. + * + * Each section in [sections] has it's own issues, these issues are only + * the ones defined before the first section heading. + */ + issues: Issue[]; + + /** + * Each markdown heading becomes an "section" of the asset. + * + * For example, if the asset medium is an "article" each heading in the + * asset becomes a section which can be displayed in search results. + * + * For "audio" assets, headings mark changes in the topic of conversation, + * and have an associated timecode. + */ + sections: Section[]; + + /** + * The relevance of this result to the current search (higher = more + * relevant). + * + * An undefined score means the result did not match the search criteria. + */ + score: number | undefined; + + /** + * The visual order of this result within the list of all results. Must be + * an integer, smallest first. + */ + order: number | undefined; + } + + type FrontMatterSource = { + url?: string; + kind: string; + title: string; + series: string; + mirrors?: string[]; + } + + type FrontMatterSpeakers = { + [key: string]: string; + } + + type FrontMatterCompletion = { + timestamps: Boolean; + notes: Boolean; + issues: Boolean; + mentions: Boolean; + "content-verified": Boolean; + content: Boolean; + "speakers-identified": Boolean; + } + + type FrontMatter = { + source: FrontMatterSource; + speakers: FrontMatterSpeakers; + completion?: FrontMatterCompletion; + added?: { + author: string; + date: string; + } + } + + type PagefindSubResultAnchor = { + id: string; + }; + + type PagefindSubResult = { + title: string; + url: string; + excerpt: string; + anchor?: PagefindSubResultAnchor; + }; + + type PagefindFragment = { + url: string; + sub_results: PagefindSubResult[]; + }; + + type PagefindResult = { + id: string; + score: number; + words: string[]; + data: () => Promise; + }; + + type PagefindResponse = { + filters: PagefindFilters; + results: PagefindResult[]; + } + + type ResultDataSection = { + id: string; + excerpt: string; + }; + + type ResultData = { + slug: string; + score: number; + sections: ResultDataSection[]; + }; + + interface HasExcerpt { + excerpt: string | null; + }; + + type AsyncToken = Tokens.Generic & { + resolveAsync?: (token: import("marked").Tokens.Generic, context: Context) => Promise; + }; + + interface PagefindFilterValues extends Object { + [value: string]: number; + } + + interface PagefindFilters extends Object { + [name: string]: PagefindFilterValues; + }; + + type Asset = { + filename: string; + date: string; + slug: string; + markdown: string; + frontMatter: FrontMatter; + html: string; + issues: Issue[]; + sections: Section[]; + errors: string[]; + contributors: Contributor[]; + } + + type Context = { + asset: Asset; + fetcher: Fetcher; + avatars: string[]; + }; + + type Page = { + asset: Asset; + html: string; + partial: string; + filters: PagefindFilters; + }; + + type ThinAsset = { + title: string; + slug: string; + date: string; + publisher: string; + sections: Section[]; + issues: Issue[]; + }; +} + +export { }; diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..fe3fe77 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,165 @@ +import * as nodePath from "path"; +import fs from "fs"; +import fm from "front-matter"; +import { parse as parseHTML } from "node-html-parser"; + +/** + * @param {string} assetFilename + * @returns {{date:string, slug:string, ext:string}} + */ +export function parseAssetFilename(assetFilename) { + const path = nodePath.parse(assetFilename); + const date = path.name.substring(0, "0000-00-00".length); + const slug = path.name.substring("0000-00-00-".length); + const ext = path.ext; + return { date, slug, ext }; +} + +/** + * @param {string} assetFilename + * @returns {Asset} + */ +export function createAssetStub(assetFilename) { + const content = fs.readFileSync(assetFilename, "utf8"); + const { date, slug } = parseAssetFilename(assetFilename); + + const { + attributes: frontMatter, + body: markdown + } = /** @type{import("front-matter").FrontMatterResult} */ (fm(content)); + + return { + date, slug, markdown, frontMatter, + filename: assetFilename, + html: "", + issues: [], + sections: [], + errors: [], + contributors: [], + }; +} + +/** + * Dummy template literal for IDE HTML syntax highlighting. + * + * @param {TemplateStringsArray} strings + * @param {string[]} values + */ +export function html(strings, ...values) { + let str = ""; + strings.forEach((string, i) => { + str += string + (values[i] || ""); + }); + return str; +} + +/** + * Dummy template literal for IDE JavaScript syntax highlighting. + */ +export const js = html; + +/** + * @param {string[]} columnList + * @returns {string} + */ +export function tableRow(...columnList) { + return html`${columnList.map(col => html`${col}`).join("")}` +} + +/** + * Parses data-pagefind-filter attributes to exctract all Pagefind filters. + * + * Pagefind's node wrapper lib doesn't say which filters it discovered, forcing + * filter lookup in the client lib, slowing time to first render of filters. + * This functions gets around that by parsing the HTML again ourselves looking + * for the same pagefind HTML element attributes which pagefind itself does to + * reconstruct the same data that [pagefind.filters()] would return. + * + * This is an upcomming feature of Pagefind, so this approach will soon be + * obsolete. See reference issues. This implementation is a best guess effort + * following the Pagefind docs, it may not perfectly match edge cases in filter + * names or values. + * + * # Reference + * + * - https://pagefind.app/docs/filtering/ + * - https://github.com/CloudCannon/pagefind/issues/715 + * - https://github.com/CloudCannon/pagefind/issues/371 + * + * # Example + * ```js + * import assert from "assert"; + * assert.deepEqual(extractPagefindFilters(` + * + * valueContent + * valueContent + * valueContent + * `), { + * singleName: { inlineContent: 1, valueContent: 1 }, + * name1: { valueContent: 2 }, + * name2: { inlineContent: 1, attrValue: 1 }, + * name3: { inlineContent: 1 } + * } + * ``` + * + * @param {string} html + * @returns {PagefindFilters} + */ +export function extractPagefindFilters(html) { + /** @type {PagefindFilters} */ + const pagefindFilters = {}; + + parseHTML(html).querySelectorAll("[data-pagefind-filter]").forEach(element => { + let signature = element.getAttribute("data-pagefind-filter"); + if (!signature) return; + + let filters = []; + + let chars = signature.split(""); + let name = ""; + let mod = ""; + + chars.forEach(char => { + switch (char) { + case ',': + if (mod[0] === ":") mod += char; + else { + filters.push([name, mod]); + name = ""; mod = ""; + } + break; + case '[': + case ':': + mod += char; + break; + case ']': + default: + if (mod) mod += char; + else name += char; + } + }); + + if (name || mod) filters.push([name, mod]); + + filters = filters.map(([name, mod]) => { + name = name.trim(); + mod = mod.trim(); + if (mod[0] === ":") { + return [name, mod.substring(1).trim()]; + } else if (mod[0] === "[") { + return [name, element.getAttribute(mod.substring(1, mod.length - 1))?.trim() || ""]; + } else { + return [name, element.textContent?.trim() || ""]; + } + }); + + filters.forEach(([name, value]) => { + if (!pagefindFilters.hasOwnProperty(name)) pagefindFilters[name] = {}; + if (!pagefindFilters[name].hasOwnProperty(value)) + pagefindFilters[name][value] = 1; + else pagefindFilters[name][value]++; + }); + }); + + return pagefindFilters; +} diff --git a/web/static/components/rpr-ad.js b/web/static/components/rpr-ad.js deleted file mode 100644 index de601ec..0000000 --- a/web/static/components/rpr-ad.js +++ /dev/null @@ -1,51 +0,0 @@ -class Ad extends HTMLElement { - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ` - -
    -

    £9.99 The Complete Works eBook

    -
    -
    - `; - } -} - -customElements.define("rpr-ad", Ad); diff --git a/web/static/components/rpr-advanced-search.js b/web/static/components/rpr-advanced-search.js deleted file mode 100644 index 53f60a3..0000000 --- a/web/static/components/rpr-advanced-search.js +++ /dev/null @@ -1,147 +0,0 @@ -class AdvancedSearch extends HTMLElement { - - /** @type {Object.} */ - #filters = {}; - - /** @type {Object.} */ - #pins = {}; - - static observedAttributes = ["filters"]; - - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ` - -

    Advanced Search

    -
    - -
    -

    -

    Ray Peat wrote articles, appeared on podcasts, and expressed himself in many mediums.

    -
    -
    - -
    -

    -

    Assets range from awaiting content, to AI audio transcripts, to verified correct text.

    -
    -
    - -
    -

    -

    Assets range from awaiting content, to AI audio transcripts, to verified correct text.

    -
    -
    - -
    -

    -

    Ray appeared on various shows and published his own newsletter.

    -
    -
    - -
    -

    -

    Many people participated in conversations with Ray Peat.

    -
    -
    - -
    -

    -

    Opportunities to improve an asset's text are tracked in "GitHub issues".

    -
    -
    - -
    -

    -

    People and concepts mentioned by participants.

    -
    -
    - -
    -

    -

    Assets were gathered from the following websites.

    -
    -
    - -
    - `; - } - - /** - * @param {string} name - * @param {string} _oldValue - * @param {string} newValue - */ - attributeChangedCallback(name, _oldValue, newValue) { - switch (name) { - case "filters": - this.#filters = JSON.parse(newValue); - - for (const [key, pins] of Object.entries(this.pins)) { - const group = this.shadowRoot?.querySelector(`[data-key="${key}"]`); - - const titleElement = group?.querySelector(".title"); - if (titleElement) { - titleElement.textContent = key; - } - - const pinsElement = group?.querySelector(".pins"); - if (pinsElement) { - pinsElement.replaceChildren(...pins); - } - } - - break; - } - } - - get filters() { - return this.#filters; - } - - set filters(newValue) { - this.setAttribute("filters", JSON.stringify(newValue)); - } - - /** - * @returns {Object.} - */ - get pins() { - return this.#pins; - } - - /** - * @param {Pin[]} newValue - */ - set pins(newValue) { - /** @type {Object.} */ - const pins = {}; - for (const pin of newValue) { - pins[pin.key] = pins[pin.key] || []; - pins[pin.key].push(pin); - pin.addEventListener("click", () => { - pin.pinned = !pin.pinned; // just to demo interactivity - }); - } - this.#pins = pins; - } -} - -customElements.define("rpr-advanced-search", AdvancedSearch); diff --git a/web/static/components/rpr-asset-excerpt.js b/web/static/components/rpr-asset-excerpt.js deleted file mode 100644 index 7ae54d5..0000000 --- a/web/static/components/rpr-asset-excerpt.js +++ /dev/null @@ -1,65 +0,0 @@ -window.customElements.define("rpr-asset-excerpt", class extends HTMLElement { - #text = ""; - - static observedAttributes = [ "text" ]; - - constructor() { - super(); - } - - connectedCallback() { - this.addEventListener("click", event => { - const asset = this.parentElement; - fetch(asset.link).then(response => { - if (!response.ok) { - throw new Error(`HTTP error: ${response.status}`) - } - return response.text(); - }).then(text => { - const parser = new DOMParser(); - const selectDoc = parser.parseFromString(text, "text/html"); - const select = selectDoc.getElementById("reading-pane"); - const target = document.getElementById("reading-pane"); - target.replaceWith(select); - - - const asset = this.parentElement; - const textParam = encodeURIComponent(this.text); - const assetLink = asset.link; - const hash = `:~:text=${textParam}`; - const link = `${assetLink}#${hash}`; - - const state = {}; - const unused = ""; - history.pushState(state, unused, assetLink); - location.hash = hash; - }).catch(error => { - console.log(error); - }); - }); - this.update(); - } - - disconnectedCallback() { - - } - - attributeChangedCallback(name, oldValue, newValue) { - if (name === "text") { - this.#text = newValue; - this.update(); - } - } - - update() { - if (!this.isConnected) return; - } - - get text() { - return this.#text; - } - - set text(text) { - this.setAttribute("text", text); - } -}); diff --git a/web/static/components/rpr-asset.js b/web/static/components/rpr-asset.js deleted file mode 100644 index 37b90c8..0000000 --- a/web/static/components/rpr-asset.js +++ /dev/null @@ -1,371 +0,0 @@ - -/** - * @typedef {object} PickEventDetail - * @property {Asset} asset - * @property {Number|null} issue - */ - -/** - * @typedef {object} PickEvent - * @property {PickEventDetail} detail - */ - -/** - * @typedef {"audio"|"video"|"text"|"dissertation"|"book"|"patent"|"thesis"|"article"} Kind - */ - -/** - * @typedef {object} IssueData - * @property {Number} Id - * @property {string} Title - * @property {string} Url - */ - - -/** - * @typedef {object} PagefindSubResult - * @property {string} excerpt - */ - -/** - * Asset title and stats, with contextual annotations for search results and - * issues. - */ -class Asset extends HTMLElement { - - /** - * Is this asset styled to appear as the active asset? - * - * @type {Boolean} - */ - #active - - /** - * The URL for this asset. - * - * @type {string} - */ - #path - - /** - * The "YYYY-MM-DD" date the asset's source was created. - * - * @type {string} - */ - #date - - /** - * The asset's capitalised title. - * - * @type {string} - */ - #title - - /** - * The kind of this asset such as audio, video or article. - * - * @type {Kind} - */ - #kind - - /** - * The published of this asset's source, such as "Some Podcast". - * - * @type {string} */ - #series - - /** - * Aa list of GitHub issues callouts in this asset's content. - * - * @type {IssueData[]} - */ - #issues = []; - - /** - * Should the asset display any issue callouts it has? - * - * @type {Boolean} - */ - #showIssues - - /** Attributes that trigger {@link attributeChangedCallback} */ - static observedAttributes = [ - "date", "title", "kind", "active", "has-matches", "path", "series", - "issues", "show-issues" - ]; - - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ` - - ${this.title} -
    - ${this.date} - ${this.series} -
    -
    -
    - `; - - const asset = this; - - this.addEventListener("click", () => { - this.dispatchEvent(new CustomEvent("pick", { - bubbles: true, - detail: /** @type {PickEventDetail} */ ({ - asset: asset, - issue: null, - }) - })); - }); - - this.addEventListener("keyup", event => { - if (event.key === "Enter") this.click(); - }); - } - - /** - * Assumes the element {@link selector} returns an element. - * - * @param {string} selector - * @returns {Element} - */ - #assume(selector) { - const element = this.shadowRoot?.querySelector(selector); - if (!element) { - throw new Error(`Failed to select "${selector}".`); - } else { - return element; - } - } - - /** - * @param {string} name - * @param {string} _oldValue - * @param {string} newValue - */ - attributeChangedCallback(name, _oldValue, newValue) { - switch (name) { - case "title": - this.#title = newValue; - this.#assume("#title").textContent = newValue; - break; - case "date": - this.#date = newValue; - this.#assume("#date").textContent = newValue; - break; - case "active": - this.#active = newValue === "true"; - break; - case "path": - this.#path = newValue; - break; - case "series": - this.#series = newValue; - this.#assume("#series").textContent = newValue; - break; - case "issues": - this.#issues = JSON.parse(newValue); - const elements = []; - for (const issue of this.issues) { - const a = document.createElement("a"); - a.href = this.path + "#issue-" + issue.Id; - a.textContent = issue.Title; - a.addEventListener("click", e => { - e.preventDefault(); - e.stopPropagation(); - this.dispatchEvent(new CustomEvent("pick", { - bubbles: true, - detail: { - asset: this, - issue: issue.Id, - }, - })); - }); - - const left = document.createElement("td"); - left.textContent = `#${issue.Id}`; - - const right = document.createElement("td"); - right.replaceChildren(a); - - const tr = document.createElement("tr"); - tr.replaceChildren(left, right); - elements.push(tr); - } - this.#assume(".issues").replaceChildren(...elements); - break; - case "show-issues": - this.#showIssues = newValue === "true"; - break; - } - }; - - get active() { - return this.#active; - } - - set active(newValue) { - this.setAttribute("active", newValue.toString()); - } - - get date() { - return this.#date; - } - - set date(date) { - this.setAttribute("date", date); - } - - get title() { - return this.#title; - } - - set title(title) { - this.setAttribute("title", title); - } - - get kind() { - return this.#kind; - } - - set kind(kind) { - this.setAttribute("kind", kind); - } - - get link() { - return this.getAttribute("link") || null; - } - - get path() { - return this.#path; - } - - set path(newValue) { - this.setAttribute("path", newValue); - } - - get series() { - return this.#series; - } - - set series(newValue) { - this.setAttribute("series", newValue); - } - - get issues() { - return this.#issues; - } - - set issues(newValue) { - this.setAttribute("issues", JSON.stringify(newValue)); - } - - get showIssues() { - return this.#showIssues; - } - - set showIssues(newValue) { - this.setAttribute("show-issues", newValue.toString()); - } - - /** This this to {@link active} if current URL matches {@link path}. */ - deriveActive() { - const currentPath = window.location.pathname.replace(/\/+$/, ""); - if (this.path === currentPath) { - this.active = true; - return true; - } - return false; - } - - /** - * Repalce visible Pagefind results. To clear pass no arguments. - * - * @param {...PagefindSubResult} pagefindSubResult - */ - replaceResults(...pagefindSubResult) { - const elements = []; - for (const psr of pagefindSubResult) { - const result = document.createElement("div"); - result.innerHTML = psr.excerpt; - elements.push(result); - } - this.#assume("#results").replaceChildren(...elements); - } -} - -customElements.define("rpr-asset", Asset); diff --git a/web/static/components/rpr-contributor.js b/web/static/components/rpr-contributor.js deleted file mode 100644 index 3611232..0000000 --- a/web/static/components/rpr-contributor.js +++ /dev/null @@ -1,83 +0,0 @@ -class Contributor extends HTMLElement { - static observedAttributes = ["avatar", "name"]; - - /** - * Absolute path to the avatar image for the person speaking or writing. - * - * @type {string} - */ - #avatar - - /** - * Full name of the person speaking or writing. - * - * @type {string} - */ - #name - - /** @type {HTMLImageElement} */ - #avatarElement - - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ` - - - `; - - this.#avatarElement = /** @type {HTMLImageElement} */ (shadowRoot.querySelector("img")); - } - - /** - * @param {string} name - * @param {string} _oldValue - * @param {string} newValue - */ - attributeChangedCallback(name, _oldValue, newValue) { - switch (name) { - case "avatar": - this.#avatar = newValue; - this.#avatarElement.src = newValue; - break; - case "name": - this.#name = newValue; - this.#avatarElement.title = newValue; - break; - } - } - - set avatar(a) { - this.setAttribute("avatar", a); - } - - set name(n) { - this.setAttribute("name", n); - } - - get avatar() { - return this.#avatar; - } - - get name() { - return this.#name; - } -} - -customElements.define("rpr-contributor", Contributor); diff --git a/web/static/components/rpr-deck.js b/web/static/components/rpr-deck.js deleted file mode 100644 index 4629e40..0000000 --- a/web/static/components/rpr-deck.js +++ /dev/null @@ -1,65 +0,0 @@ -class Deck extends HTMLElement { - #stageElement - - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ` - -
    - `; - - this.#stageElement = /** @type {HTMLElement} */ (shadowRoot.querySelector("#stage")); - - /** - * @type {(event: Event) => void} - * @param {PickEvent} event - */ - function pickHandler(event) { - /** @type {Asset|null} */ - const current = shadowRoot.querySelector(`[active="true"]`); - - const asset = event.detail.asset; - - if (current !== asset) { - if (current !== null) { - current.active = false; - } - asset.active = true; - this.dispatchEvent(new CustomEvent("pick", { - bubbles: true, - detail: event.detail, - })); - } - - } - - shadowRoot.addEventListener("pick", pickHandler); - } - - /** - * @param {...HTMLElement} elements - */ - append(...elements) { - this.#stageElement.append(...elements); - } - - /** - * @param {...HTMLElement} elements - */ - replace(...elements) { - this.#stageElement.replaceChildren(...elements); - } -} - -customElements.define("rpr-deck", Deck); diff --git a/web/static/components/rpr-issue.js b/web/static/components/rpr-issue.js deleted file mode 100644 index c7a459f..0000000 --- a/web/static/components/rpr-issue.js +++ /dev/null @@ -1,129 +0,0 @@ -class Issue extends HTMLElement { - - /** @type {Number} */ - #issueId - - /** @type {string} */ - #url - - /** @type {string} */ - #title - - /** @type {HTMLElement} */ - #idElement - - /** @type {HTMLElement} */ - #titleElement - - /** @type {HTMLAnchorElement} */ - #issueElement - - static observedAttributes = ["issue-id", "url", "title"]; - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }) - shadowRoot.innerHTML = ` - - - # - - - `; - - this.#idElement = /** @type {HTMLElement} */ (shadowRoot.querySelector("#id")); - this.#titleElement = /** @type {HTMLElement} */ (shadowRoot.querySelector("#title")); - this.#issueElement = /** @type {HTMLAnchorElement} */ (shadowRoot.querySelector("#issue")); - } - - /** - * @param {string} name - * @param {string} _oldValue - * @param {string} newValue - */ - attributeChangedCallback(name, _oldValue, newValue) { - switch (name) { - case "issue-id": - this.#issueId = parseInt(newValue); - this.setAttribute("id", `issue-${newValue}`); - this.#idElement.textContent = newValue; - break; - case "url": - this.#url = newValue; - this.#issueElement.href = newValue; - break; - case "title": - this.#title = newValue; - this.#titleElement.textContent = newValue; - break; - } - } - - set issueId(newValue) { - this.setAttribute("issue-id", newValue.toString()); - } - - get issueId() { - return this.#issueId - } - - set url(newValue) { - this.setAttribute("url", newValue); - } - - get url() { - return this.#url; - } - - set title(newValue) { - this.setAttribute("title", newValue); - } - - get title() { - return this.#title; - } -} - -customElements.define("rpr-issue", Issue); diff --git a/web/static/components/rpr-layout.js b/web/static/components/rpr-layout.js deleted file mode 100644 index c58ffad..0000000 --- a/web/static/components/rpr-layout.js +++ /dev/null @@ -1,216 +0,0 @@ - -/** - * @typedef {object} SidebarEvent - * @property {"open"|"close"|"toggle"} detail - */ - -/** - * A main content area with a togglable sidebar. Handles the picking of new - * {@link Asset|Assets} by fetching it's content and updaing the DOM. - */ -class Layout extends HTMLElement { - /** Is the sidebar visible? */ - #sidebar = true; - - /** Attributes which trigger {@link attributeChangedCallback} */ - static observedAttributes = ["sidebar"]; - - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ` - -
    - -
    - - - `; - - const search = /** @type {Search} */ (shadowRoot.querySelector("rpr-search")); - const readingPane = /** @type {HTMLDivElement} */ (shadowRoot.querySelector("#reading-pane")); - const sideElement = /** @type {HTMLElement} */ (shadowRoot.querySelector(".side")); - const parser = new DOMParser(); - - sideElement.addEventListener("click", () => { - if (!this.sidebar) { - this.sidebar = true; - } - }); - - /** - * @type {(event: Event) => Promise} - * @param {PickEvent} event - */ - async function pickHandler(event) { - const asset = event.detail.asset; - const response = await fetch(asset.path); - if (!response.ok) { - console.error(`Failed to fetch asset ${asset.path}`); - return; - } - const doc = parser.parseFromString(await response.text(), "text/html"); - - const newPane = doc.querySelector("#reading-pane"); - if (!newPane) { - console.error("Failed to find #reading-pane in fetched content"); - return - } - - readingPane.replaceChildren(newPane); - window.scrollTo(0, 0); - history.pushState({}, "", asset.path); - } - shadowRoot.addEventListener("pick", pickHandler); - - const layout = this; - - /** - * @function - * @type {(event: Event) => void} - * @param {SidebarEvent} event - */ - function sidebarHandler(event) { - switch (event.detail) { - case "open": - layout.sidebar = true; - break; - case "close": - layout.sidebar = false; - break; - case "toggle": - layout.sidebar = !layout.sidebar; - break; - } - } - shadowRoot.addEventListener("sidebar", sidebarHandler); - - shadowRoot.addEventListener("search", () => { - this.sidebar = true; - search.focus(); - search.select(); - }); - } - - connectedCallback() { - const params = new URLSearchParams(window.location.search); - if (params.get("sidebar") === "false") { - this.sidebar = false; - } else { - this.sidebar = true; - } - } - - /** - * @param {string} name - * @param {string} _oldValue - * @param {string} newValue - */ - attributeChangedCallback(name, _oldValue, newValue) { - switch (name) { - case "sidebar": - this.#sidebar = newValue === "true"; - break; - } - } - - /** Set the visibility of the sidebar */ - set sidebar(newValue) { - this.setAttribute("sidebar", newValue.toString()); - } - - /** Is the sidebar visible? */ - get sidebar() { - return this.#sidebar; - } - -} - -customElements.define("rpr-layout", Layout); diff --git a/web/static/components/rpr-new-issue.js b/web/static/components/rpr-new-issue.js deleted file mode 100644 index f5f8705..0000000 --- a/web/static/components/rpr-new-issue.js +++ /dev/null @@ -1,140 +0,0 @@ -class NewIssue extends HTMLElement { - - /** @type {HTMLAnchorElement} */ - #newIssueElement - - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ` - - -
    -
    - Plus icon -
    - -
    -
    Email Ray Peat Rodeo
    -
    - `; - - this.#newIssueElement = /** @type {HTMLAnchorElement} */ (shadowRoot.querySelector("#new-issue")); - - "mouseup touchend".split(" ").forEach(e => window.addEventListener(e, () => { - const selection = window.getSelection(); - if (!selection) { - return; - } - - const selectedText = selection.toString(); - const gap = 16; - - // The mouseup event may indicate either that a text selection had been - // created or removed. When removed we must wait 1ms before checking to - // get the correct state. - setTimeout(() => { - if (!selection.isCollapsed) { - this.style.display = "block"; - const selRect = selection.getRangeAt(0).getBoundingClientRect(); - const articleElement = document.querySelector("article"); - if (!articleElement) { - return; - } - const articleRect = articleElement.getBoundingClientRect(); - - this.#newIssueElement.style.top = (selRect.bottom + gap - articleRect.top) + "px"; - this.#newIssueElement.style.left = (selRect.left - articleRect.left) + "px"; - - const titleElement = document.querySelector("h1"); - if (!titleElement) { - return; - } - - const assetTitle = titleElement.textContent; - const assetLink = `https://raypeat.rodeo${window.location.pathname}`; - const title = encodeURIComponent(`Issue with "${assetTitle}"`); - const body = encodeURIComponent(` -

    Hi, I've found an issue with this text from ${assetTitle}:

    -
    ${selectedText}
    -

    [consider describing the issue here]

    - `); - this.#newIssueElement.href = `mailto:contact@raypeat.rodeo?subject=${title}&body=${body}`; - } else { - this.style.display = "none"; - } - }, 1); - })); - - } -} - -customElements.define("rpr-new-issue", NewIssue); diff --git a/web/static/components/rpr-pin.js b/web/static/components/rpr-pin.js deleted file mode 100644 index 175d8a8..0000000 --- a/web/static/components/rpr-pin.js +++ /dev/null @@ -1,206 +0,0 @@ -class Pin extends HTMLElement { - - /** @type {string} */ - #key - - /** @type {string} */ - #value - - /** @type {Boolean} */ - #pinned - - /** @type {[Number, Number][]} */ - #highlights = []; - - /** @type {HTMLElement} */ - #keyElement - - /** @type {HTMLElement} */ - #valueElement - - /** attributes that trigger {@link attributeChangedCallback}. */ - static observedAttributes = ["pinned", "matches", "value", "key"]; - - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ` - - ${this.#key} - ${this.#value} - X - `; - - this.#keyElement = /** @type {HTMLElement} */ (shadowRoot.querySelector("#key")); - this.#valueElement = /** @type {HTMLElement} */ (shadowRoot.querySelector("#value")); - } - - connectedCallback() { - } - - set value(value) { - this.setAttribute("value", value); - } - - get value() { - return this.getAttribute("value") || ""; - } - - set key(key) { - this.setAttribute("key", key); - } - - get key() { - return this.getAttribute("key") || ""; - } - - set pinned(p) { - this.setAttribute("pinned", p.toString()); - } - - get pinned() { - return this.#pinned; - } - - set highlights(newValue) { - this.setAttribute("matches", newValue.flat(Infinity).join(",")); - } - - get highlights() { - return this.#highlights; - } - - hasHighlights() { - return this.#highlights.length >= 1 - } - - - /** - * @param {string} name - * @param {string} _oldValue - * @param {string} newValue - */ - attributeChangedCallback(name, _oldValue, newValue) { - switch (name) { - case "pinned": - this.#pinned = newValue === "true"; - this.dispatchEvent(new CustomEvent(this.pinned ? "pinned" : "unpinned", { - bubbles: true, - detail: { - key: this.#key, - value: this.#value - }, - })); - this.reflowValue(); - break; - case "matches": - const numbers = newValue.split(",").map(x => Number(x)); - - /** @type {[Number, Number][]} */ - const matches = []; - for (let i = 0; i + 1 < numbers.length; i += 2) { - matches.push([numbers[i], numbers[i + 1]]); - } - this.#highlights = matches; - this.reflowValue(); - break; - case "value": - this.#value = newValue; - this.#valueElement.textContent = newValue; - this.reflowValue(); - break; - case "key": - this.#key = newValue; - this.#keyElement.textContent = this.key; - this.reflowValue(); - break; - } - } - - reflowValue() { - if (this.#pinned || !this.hasHighlights()) { - this.#valueElement.textContent = this.value; - return; - } - - let pointer = 0; - const fragment = new DocumentFragment(); - for (const [start, end] of this.highlights) { - if (start > pointer) { - fragment.append(this.#value.substring(pointer, start)); - } - fragment.append((() => { - const mark = document.createElement("mark"); - mark.append(this.#value.substring(start, end)); - return mark; - })()); - pointer = end; - } - fragment.append(this.#value.substring(pointer)); - this.#valueElement.replaceChildren(fragment); - } -} - -customElements.define("rpr-pin", Pin); diff --git a/web/static/components/rpr-pins.js b/web/static/components/rpr-pins.js deleted file mode 100644 index cc2200f..0000000 --- a/web/static/components/rpr-pins.js +++ /dev/null @@ -1,86 +0,0 @@ -class Pins extends HTMLElement { - - /** @type {Number} */ - #tabIndexStart = 2; - - /** @type {Number} */ - #tabIndexEnd = 2 - - /** @type {Pin[]} */ - #pinned = []; - - /** @type {Pin[]} */ - #unpinned = []; - - /** @type {HTMLElement} */ - #pinnedElement - - /** @type {HTMLElement} */ - #unpinnedElement - - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ` - -
    -
    - `; - - this.#pinnedElement = /** @type {HTMLElement} */ (shadowRoot.querySelector("#pinned")); - this.#unpinnedElement = /** @type {HTMLElement} */ (shadowRoot.querySelector("#unpinned")); - } - - /** - * @param {...Pin} pins - */ - replacePinned(...pins) { - this.#pinned = pins; - this.#pinnedElement.replaceChildren(...pins); - this.#recalculateTabIndexes(); - } - - /** - * @param {...Pin} pins - */ - replaceUnpinned(...pins) { - this.#unpinned = pins; - this.#unpinnedElement.replaceChildren(...pins); - this.#recalculateTabIndexes(); - } - - #recalculateTabIndexes() { - let tabIndex = this.#tabIndexStart; - for (const pin of this.#unpinned) pin.tabIndex = tabIndex++; - for (const pin of this.#pinned) pin.tabIndex = tabIndex++; - this.#tabIndexEnd = tabIndex; - } - - get tabIndexEnd() { - return this.#tabIndexEnd; - } -} - -customElements.define("rpr-pins", Pins); diff --git a/web/static/components/rpr-ray-peat.js b/web/static/components/rpr-ray-peat.js deleted file mode 100644 index d102072..0000000 --- a/web/static/components/rpr-ray-peat.js +++ /dev/null @@ -1,19 +0,0 @@ -class RayPeat extends HTMLElement { - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ` - -

    Ray Peat

    -

    Ray Peat (1936 - 2022) was a biologist and teacher.

    - `; - } -} - -customElements.define("rpr-ray-peat", RayPeat); diff --git a/web/static/components/rpr-search.js b/web/static/components/rpr-search.js deleted file mode 100644 index a3b21a1..0000000 --- a/web/static/components/rpr-search.js +++ /dev/null @@ -1,831 +0,0 @@ -const pagefind = new Promise(resolve => { - import("/pagefind/pagefind.js").then(pagefind => { - pagefind.options({ - highlightParam: 'highlight', - excerptLength: 60, - showSubResults: true, - }).then(() => { - resolve(pagefind); - }); - }); -}); - -const fuzzy = new uFuzzy({ - intraChars: ".", // Allows any characters between matches - intraIns: 3, // Allows any amount of characters between matches -}); - -const interrupt = () => new Promise(resolve => setZeroTimeout(resolve)); - -class Search extends HTMLElement { - /** @type {string} */ - #query = ""; - - /** @type {Object.} */ - #filters = {}; - - /** @type {Object.} */ - #prevFilters = {}; - - #searchCount = 0; - - /** @type {Promise>} */ - #assets - - /** @type {Promise} */ - #assetsNewestFirst; - - /** @type {Deck} */ - #deckElement - - /** @type {Promise} */ - #pins - - /** @type {Boolean} */ - #interactive - - /** @type {HTMLElement} */ - #readingPaneElement - - /** @type {HTMLInputElement} */ - #inputElement - - /** @type {Pins} */ - #pinsElement - - /** @type {HTMLButtonElement} */ - #sidebarButton - - static observedAttributes = ["query", "filters", "interactive"]; - static #domParser = new DOMParser(); - - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ` - - - - `; - - this.#deckElement = /** @type {Deck} */ (shadowRoot.querySelector("#deck")); - this.#readingPaneElement = /** @type {HTMLElement} */ (shadowRoot.querySelector("#reading-pane")); - this.#inputElement = /** @type {HTMLInputElement} */ (shadowRoot.querySelector("#input")); - this.#pinsElement = /** @type {Pins} */ (shadowRoot.querySelector("#pins")); - this.#sidebarButton = /** @type {HTMLButtonElement} */ (shadowRoot.querySelector("button.sidebar")); - const advancedSearch = /** @type {HTMLElement} */ (shadowRoot.querySelector(".advanced-search")); - const rayPeatElement = /** @type {HTMLElement} */ (shadowRoot.querySelector(".ray-peat")); - - rayPeatElement.addEventListener("click", () => { - const pane = document.querySelector("#reading-pane"); - if (pane) { - pane.replaceChildren(new RayPeat()); - } - }); - - advancedSearch.addEventListener("click", async () => { - const advancedSearchPage = new AdvancedSearch(); - advancedSearchPage.pins = /** @type {Pin[]} */ ((await this.#pins).map(pin => pin.cloneNode())); - advancedSearchPage.filters = this.filters; - const pane = document.querySelector("#reading-pane"); - if (pane) { - pane.replaceChildren(advancedSearchPage); - } - }); - - this.#sidebarButton.addEventListener("click", event => { - this.dispatchEvent(new CustomEvent("sidebar", { - bubbles: true, - detail: "toggle", - })); - event.stopPropagation(); - }); - - this.#inputElement.addEventListener("keyup", () => { - this.query = this.#inputElement.value; - }); - - this.focus = () => this.#inputElement.focus(); - this.select = () => this.#inputElement.select(); - - window.addEventListener("keydown", event => { - if (event.key === "/") { - if (this !== document.activeElement) { - this.dispatchEvent(new CustomEvent("sidebar", { - bubbles: true, - detail: "open", - })); - this.#inputElement.focus(); - this.#inputElement.select(); - event.preventDefault(); - } - } else if (event.key === "Escape" && this === document.activeElement) { - this.#inputElement.blur(); - } - }); - - - this.#assets = new Promise(async resolve => { - const response = await fetch("/search.json"); - if (!response.ok) { - console.error(`Failed to fetch /search.json`); - return null; - } - - const assetList = await response.json(); - if (assetList.length <= 0) { - console.error(`Found 0 assets listed at /search.json`); - return null; - } - - let tabIndex = 10000; - let activeAsset = null; - - /** @type {Object.} */ - const assets = {}; - - for (const asset of assetList) { - const a = new Asset(); - a.path = asset.Path; - a.title = asset.Title; - a.series = asset.Series; - a.date = asset.Date; - a.kind = asset.Kind; - a.issues = asset.Issues; - a.tabIndex = tabIndex++; - - if (a.deriveActive()) { - activeAsset = a; - } - - /** - * @type {(event: Event) => Promise} - * @param {PickEvent} event - */ - async function pickHandler(event) { - const response = await fetch(a.path); - const issue = event.detail.issue; - - if (!response.ok) { - console.error(`Failed to fetch ${a.path}`); - return; - } - - const text = await response.text(); - const doc = Search.#domParser.parseFromString(text, "text/html"); - - const grabbed = doc.querySelector("#reading-pane"); - if (!grabbed) { - throw new Error(`Failed to find #reading-pane.`); - } - - // #reading-pane must be re-selected every time because it may be replaced in the DOM. - const readingPane = document.querySelector("#reading-pane"); - if (!readingPane) { - throw new Error("Failed to find #reading-pane"); - } - - readingPane.replaceWith(grabbed); - - if (issue === null) { - window.scrollTo({ top: 0, behavior: "instant" }); - } else { - const issueBubble = /** @type {Issue|null} */ (document.querySelector(`#issue-${issue}`)); - if (issueBubble) { - window.scrollTo({ top: issueBubble.offsetTop, behavior: "instant" }); - } - } - - let link = a.path; - if (window.location.search) link += window.location.search; - if (window.location.hash) link += window.location.hash; - history.pushState({}, "", link); - } - - a.addEventListener("pick", pickHandler); - assets[a.path] = a; - } - - const newsetFirst = Object.values(assets) - .sort((a, b) => b.date.localeCompare(a.date)); - - if (activeAsset === null) { - activeAsset = newsetFirst[0]; - activeAsset.active = true; - } - - this.#deckElement.replace(...newsetFirst); - - if (!this.#interactive) { - activeAsset.scrollIntoView({ - block: "center", - behavior: "instant", - }); - } - - resolve(assets); - }); - - this.#pins = new Promise(async resolve => { - const pf = await pagefind; - const filters = await pf.filters(); - - const pins = []; - for (const [key, values] of Object.entries(filters)) { - for (const value of Object.keys(values)) { - const pin = new Pin(); - pin.key = key; - pin.value = value; - - pin.addEventListener("click", async () => { - if (this.togglePin(pin)) { - this.query = ""; - } - - const isTouchScreen = window.matchMedia("(pointer: coarse)").matches; - if (!isTouchScreen) { - this.select(); - } - }); - - pin.addEventListener("keyup", event => { - if (event.key === "Enter") pin.click() - }); - - pins.push(pin); - } - } - - resolve(pins); - }); - - this.#assetsNewestFirst = new Promise(async resolve => { - const elements = Object.values(await this.#assets); - const sorted = elements.sort((a, b) => b.date.localeCompare(a.date)); - resolve(sorted); - }); - } - - async connectedCallback() { - const params = new URLSearchParams(window.location.search); - const search = params.get("search"); - if (search !== null) { - params.delete("search"); - this.query = search; - } - - if (params.toString() === "") { - if (this.query !== "") { - await this.#search(); - } else { - this.interactive = true; - } - } - - /** @type {Object.} */ - const filters = {}; - - for (const pin of (await this.#pins)) { - if (params.getAll(pin.key).includes(pin.value)) { - filters[pin.key] = filters[pin.key] || []; - filters[pin.key].push(pin.value); - } - this.filters = filters; - } - - if (this.query !== "" || !Search.filtersMatch(filters, {})) { - await this.#search(); - } else { - this.interactive = true; - } - } - - /** - * @param {[string, string]} a - * @param {[string, string]} b - */ - static sortFiltersAlphabetically(a, b) { - const key = a[0].localeCompare(b[0]); - if (key !== 0) { - return a; - } - const val = a[1].localeCompare(b[1]); - return val; - } - - /** - * @param {Object.} newFilters - * @returns {Object.} - */ - static sanitiseFilters(newFilters) { - /** @type {Object.} */ - const result = {}; - - for (const key of Object.keys(newFilters).sort()) { - result[key] = newFilters[key].sort(); - } - return result; - } - - - /** - * @param {Object.} a - * @param {Object.} b - */ - static filtersMatch(a, b) { - const aKeys = Object.keys(a); - const bKeys = Object.keys(b); - if (aKeys.length !== bKeys.length) { - return false; - } - return aKeys.every(key => { - const aValues = a[key]; - const bValues = b[key]; - if (aValues.length !== bValues.length) { - return false; - } - return aValues.every((val, i) => val === bValues[i]); - }); - } - - /** - * @param {string} name - * @param {string} oldValue - * @param {string} newValue - */ - attributeChangedCallback(name, oldValue, newValue) { - switch (name) { - case "query": - this.#query = newValue; - this.#inputElement.value = newValue; - if (newValue !== oldValue && this.interactive) { - this.#search(); - } - break; - case "filters": - if (newValue !== oldValue) { - this.#filters = Search.sanitiseFilters(JSON.parse(newValue)); - if (this.interactive) { - if (!Search.filtersMatch(this.#filters, this.#prevFilters)) { - this.#search(); - } - } - this.#prevFilters = structuredClone(this.#filters); - } - break; - case "interactive": - this.#interactive = newValue === "true"; - break; - } - } - - async #search() { - const searchCount = ++this.#searchCount; - this.#deckElement.replace(); - - // 🪟🔗 Update Window Location - - (async () => { - if (!this.interactive) { - return; - } - - const params = new URLSearchParams(); - for (const [key, values] of Object.entries(this.filters)) { - for (const value of values) { - params.append(key, value); - } - } - - if (this.query !== "") { - params.append("search", this.query); - } - - let link = window.location.pathname; - - const search = params.toString(); - if (search !== "") link += `?${search}`; - - if (window.location.hash !== "") link += window.location.hash; - - history.replaceState({}, "", link); - })(); - - // 📌 Filter pins - - (async () => { - const query = this.query.replaceAll('"', ""); - - const pins = []; - for (const pin of await this.#pins) { - pin.pinned = this.isPinned(pin); - pins.push({ - pin, - pinned: pin.pinned, - order: -1 - }); - } - - const values = pins.map(pin => pin.pin.value); - const [unordered, info, ordered] = fuzzy.search(values, query); - - if (unordered === null || unordered.length <= 0) { - this.#pinsElement.replaceUnpinned(); - const pinned = pins.filter(pin => pin.pinned).map(pin => pin.pin); - this.#pinsElement.replacePinned(...pinned); - return; - } - - for (const [orderedIndex, unorderedIndex] of ordered.entries()) { - const pin = pins[unordered[unorderedIndex]]; - pin.pin.highlights = info.ranges[unorderedIndex] || []; - pin.order = orderedIndex; - } - - if (this.interactive) { - const unpinned = pins - .filter(p => !p.pinned && p.order >= 0) - .sort((a, b) => a.order - b.order) - .map(m => m.pin); - this.#pinsElement.replaceUnpinned(...unpinned); - } - - const pinned = pins.filter(m => m.pinned).map(m => m.pin); - this.#pinsElement.replacePinned(...pinned); - })(); - - - // 📃 Filter and search assets - - if (this.query === "" && Search.filtersMatch(this.filters, {})) { - this.#deckElement.replace(...await this.getDefaultAssets()); - return false; - } - - const forceQuery = this.query || null; - const sort = {}; - if (this.query === "") { - sort["date"] = "desc"; - } - const filters = this.filters; - const result = await (await pagefind).search( - forceQuery, - { filters, sort } - ); - - if (searchCount !== this.#searchCount) { - return false; - } - - if (result === null) { - this.#deckElement.replace(...await this.getDefaultAssets()); - return false; - } - - const trailingSlashes = /\/+$/; - let tabIndex = (await this.#pins).length + 10; - - /** @type {Asset|null} */ - let activeAsset = null; - - const assets = await this.#assets; - - /** - * @typedef {Object} PagefindResult - * @param {string} raw_url - * @param {PagefindSubResult} sub_results - */ - - /** - * @param {PagefindResult} data - * @returns {Asset} - */ - function toElement(data) { - const key = data.raw_url.replace(trailingSlashes, ""); - const asset = assets[key]; - asset.tabIndex = tabIndex++; - - asset.showIssues = filters.hasOwnProperty("Issues") - && filters["Issues"].includes("Has Issues"); - - if (typeof asset === "undefined") { - throw new Error("Asset not found"); - } - - asset.replaceResults(...data.sub_results); - return asset; - } - - // Show a usefull amount of results as quick as possible - const burstSize = 10; - const realSize = Math.min(burstSize, result.results.length); - const burst = result.results.splice(0, realSize); - - const promises = new Array(realSize); - for (const i in burst) { - promises[i] = new Promise(async resolve => { - const data = await burst[i].data(); - const asset = toElement(data); - if (searchCount === this.#searchCount) { - this.#deckElement.append(asset); - } - resolve(asset); - }); - await interrupt(); - } - - - // A great moment to make the UI interactive; - this.interactive = true; - - if (searchCount !== this.#searchCount) { - return false; - } - - // Slow down the remainder a little to prevent UI hanging - const remainingPromises = new Array(result.results.length); - for (const i in result.results) { - remainingPromises[i] = new Promise(async resolve => { - const data = await result.results[i].data(); - resolve(toElement(data)); - }); - await interrupt(); - } - - const remainingAssets = await Promise.all(remainingPromises); - - if (searchCount !== this.#searchCount) return false; - this.#deckElement.append(...remainingAssets); - - const allAssets = await Promise.all(promises); - allAssets.push(remainingAssets); - - for (const asset of allAssets) { - if (asset.active) { - activeAsset = asset; - } - } - - if (activeAsset && !this.interactive) { - activeAsset.scrollIntoView({ - block: "center", - behavior: "instant", - }); - } - - return true; - } - - get query() { - return this.#query; - } - - set query(newValue) { - this.setAttribute("query", newValue); - } - - get filters() { - return this.#filters; - } - - set filters(newValue) { - this.setAttribute("filters", JSON.stringify(newValue)); - } - - get interactive() { - return this.#interactive; - } - - set interactive(newValue) { - this.setAttribute("interactive", newValue.toString()); - } - - async getDefaultAssets() { - const assets = await this.#assetsNewestFirst; - - let tabIndex = this.#pinsElement.tabIndexEnd; - for (const asset of assets) { - asset.replaceResults(); - asset.showIssues = false; - asset.tabIndex = ++tabIndex; - } - - return assets; - } - - /** - * @param {Pin} pin - * @returns {Boolean} - */ - togglePin(pin) { - const isPinned = this.isPinned(pin); - isPinned ? this.unpin(pin) : this.pin(pin); - return !isPinned; - } - - /** - * @param {Pin} pin - */ - pin(pin) { - const filters = structuredClone(this.filters); - let values = filters[pin.key] || []; - values.push(pin.value); - values = [...new Set(values)]; - filters[pin.key] = values.sort(); - this.filters = filters; - } - - /** - * @param {Pin} pin - */ - unpin(pin) { - const filters = structuredClone(this.filters); - const values = filters[pin.key] || []; - const index = values.indexOf(pin.value); - if (index >= 0) { - values.splice(index, 1); - if (values.length > 0) { - filters[pin.key] = values; - } else { - delete filters[pin.key]; - } - } - this.filters = filters; - } - - /** - * @param {Pin} pin - * @returns {Boolean} - */ - isPinned(pin) { - return this.filters.hasOwnProperty(pin.key) && this.filters[pin.key].includes(pin.value); - } -} - -customElements.define("rpr-search", Search); - diff --git a/web/static/components/rpr-sidenote.js b/web/static/components/rpr-sidenote.js deleted file mode 100644 index b8a9521..0000000 --- a/web/static/components/rpr-sidenote.js +++ /dev/null @@ -1,92 +0,0 @@ -class Sidenode extends HTMLElement { - /** @type {string} */ - #sidenoteId - - static observedAttributes = ["sidenote-id"]; - - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ` - - - `; - } - - /** - * @param {string} name - * @param {string} _oldValue - * @param {string} newValue - */ - attributeChangedCallback(name, _oldValue, newValue) { - switch (name) { - case "sidenote-id": - this.#sidenoteId = newValue; - this.setAttribute("id", `sidenote-${newValue}`); - break; - } - } - - set sidenoteId(newValue) { - this.setAttribute("sidenote-id", newValue); - } - - get sidenoteId() { - return this.#sidenoteId; - } -} - -customElements.define("rpr-sidenote", Sidenode); diff --git a/web/static/components/rpr-toolbar.js b/web/static/components/rpr-toolbar.js deleted file mode 100644 index c4be771..0000000 --- a/web/static/components/rpr-toolbar.js +++ /dev/null @@ -1,26 +0,0 @@ -class Toolbar extends HTMLElement { - - - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }) - shadowRoot.innerHTML = ` - - - `; - - } -} - -customElements.define("rpr-toolbar", Toolbar); diff --git a/web/static/components/rpr-utterance.js b/web/static/components/rpr-utterance.js deleted file mode 100644 index 3713906..0000000 --- a/web/static/components/rpr-utterance.js +++ /dev/null @@ -1,219 +0,0 @@ -class Utterance extends HTMLElement { - /** - * Full name of the person uttering this utterance. - * - * @type {string} - */ - #by - - /** - * Absolute path to the avatar image for this person. - * - * @type {string} - */ - #avatar - - /** - * Is the person speaking a "primary" speaker? - * - * @type {Boolean} - */ - #primary - - /** - * Should this utterance be displyed as a short utterance? The avatar and - * byline will be hidden, the width may shrink, and it will overlap with - * the previous utterance. - * - * @type {Boolean} - */ - #short - - /** @type {HTMLElement} */ - #nameElement - - /** @type {HTMLElement} */ - #contentElement - - /** @type {HTMLImageElement} */ - #avatarImgElement - - static observedAttributes = ["by", "avatar", "primary", "short"]; - - constructor() { - super(); - const shadowRoot = this.attachShadow({ mode: "open" }); - shadowRoot.innerHTML = ` - -
    -
    -
    - -
    -
    -
    -
    - -
    -
    - `; - - this.#nameElement = /** @type {HTMLElement} */ (shadowRoot.querySelector("#name")); - this.#avatarImgElement = /** @type {HTMLImageElement} */ (shadowRoot.querySelector("#avatar img")); - } - - /** - * @param {string} name - * @param {string} _oldValue - * @param {string} newValue - */ - attributeChangedCallback(name, _oldValue, newValue) { - switch (name) { - case "by": - this.#by = newValue; - this.#nameElement.textContent = newValue; - this.#avatarImgElement.alt = newValue; - this.#avatarImgElement.title = newValue; - break; - case "avatar": - this.#avatar = newValue; - this.#avatarImgElement.src = newValue; - break; - case "primary": - this.#primary = newValue === "true"; - break; - case "short": - this.#short = newValue === "true"; - break; - } - } - - set by(newValue) { - this.setAttribute("by", newValue); - } - - get by() { - return this.#by; - } - - set avatar(newValue) { - this.setAttribute("avatar", newValue); - } - - get avatar() { - return this.#avatar; - } - - set primary(newValue) { - this.setAttribute("primary", newValue.toString()); - } - - get primary() { - return this.#primary; - } - - set short(newValue) { - this.setAttribute("short", newValue.toString()); - } - - get short() { - return this.#short; - } -} - -customElements.define("rpr-utterance", Utterance); diff --git a/web/static/scripts/relative-date.js b/web/static/scripts/relative-date.js deleted file mode 100644 index 81f15fb..0000000 --- a/web/static/scripts/relative-date.js +++ /dev/null @@ -1,27 +0,0 @@ -function relativeDate(origStr) { - let buildDate = Date.parse(origStr); - let delta = Date.now() - buildDate; - - let hours = Math.floor(delta / (60 * 60 * 1000)); - if (hours < 24) { - return 'today'; - } - - let days = Math.floor(delta / (24 * 60 * 60 * 1000)); - if (days == 1) { - return 'today'; - } else if (days == 2) { - return 'yesterday'; - } else if (days < 7) { - return days + ' days ago'; - } - - let weeks = Math.floor(delta / (7 * 24 * 60 * 60 * 1000)); - if (weeks == 1) { - return 'a week ago'; - } else if (weeks <= 16) { - return weeks + ' weeks ago'; - } - - return origStr -} diff --git a/web/static/style.css b/web/static/style.css deleted file mode 100644 index b4b368c..0000000 --- a/web/static/style.css +++ /dev/null @@ -1,528 +0,0 @@ - -/* Global styles */ - -body { - max-width: 1300px; - margin: 0 auto; - font-family: sans-serif; - line-height: 1.6em; - counter-reset: sidenote-counter; -} -article, -#top-bar { - padding: 20px; - position: relative; -} -article header, -article div.speaker, -article section, -body footer, -#top-bar a { - width: 60%; - margin-left: 5%; - transition: width .1s; -} - -sup, sub { - vertical-align: baseline; - position: relative; - top: -0.4em; -} -sub { - top: 0.4em; -} - - -/* Top bar on every page */ - -#top-bar { - padding-top: 80px; -} - -#top-bar a { - text-decoration: none; - color: black; - opacity: 0.3; - font-size: 3em; - font-weight: lighter; - font-family: "Times New Roman"; - text-transform: uppercase; - letter-spacing: 10px; -} -#top-bar a:hover { - color: black; - opacity: 0.6; -} - -#top-bar #search { - position: absolute; - top: 70px; - right: 0; - width: 30%; - z-index: 1000; -} -#top-bar #search .pagefind-ui__drawer { - background: white; -} -#top-bar #search p.pagefind-ui__result-title { - width: 100%; -} -#top-bar #search .pagefind-ui__result-title a { - font-family: inherit; - font-size: 1em; - color: black; - opacity: 1; - letter-spacing: inherit; - text-transform: inherit; -} - - -/* Footer on every page */ - -body footer { - padding: 60px 20px; - font-size: 0.8em; - opacity: 0.8; -} - - -/* Sidenotes on any page */ - -article .sidenote-number { - counter-increment: sidenote-counter; -} -article .sidenote-number:after { - content: counter(sidenote-counter); - top: -0.2rem; - left: 0.0rem; -} -article .sidenote-number:after, .sidenote:before { - font-family: et-book-roman-old-style; - font-size: 0.8rem; - position: relative; - vertical-align: baseline; -} -article input.sidenote-toggle { - display: none; -} - -article .sidenote { - font-size: 0.9em; - float: right; - clear: right; - width: 40%; - margin-right: -50%; - position: relative; - padding-bottom: 10px; -} -article .sidenote:before { - content: counter(sidenote-counter) " "; - right: 102%; - position: absolute; -} -article .sidenote-meta { - padding-top: 60px; -} -article .sidenote-meta:before { - content: ""; -} -article .sidenote-standalone:before { - content: ""; -} -article .sidenote img { - width: 100%; - margin: 10px 0; -} - - -/* Interview elements - All pages are .interview currently */ - -article.interview > header { - padding-bottom: 10px; - border-bottom: 1px solid #eee; -} -article.interview > header > .hud { - font-size: 0.9em; - color: #666; -} -article.interview > header > .hud > .date { - position: relative; - top: 30px; - float: left; - clear: left; - margin-left: -17%; - width: 100px; - text-align: right; - font-family: "Courier New"; - color: #999; - font-size: 0.9em; - - float: none; - clear: none; - margin-left: 0; - padding-right: 5px; - font-family: inherit; - top: 0; -} -article.interview > header > .hud > .date:after { - content: " /"; -} -article.interview > header > .hud > a.series { - color: #666; -} -article.interview > header > .hud > a.series:hover { - color: black; - text-decoration: underline; -} -article.interview > header > h1.title { - margin: 0; -} -article.interview > header > .transcription-attribution { - margin-top: 0px; - font-size: 0.9em; -} -article.interview > header > .actions { - margin-top: 4px; - margin-bottom: 5px; -} -article.interview > header > .actions a { - background-color: #AAA; - color: #fff; - padding: 4px 10px; - border-radius: 6px; - margin-right: 10px; - font-weight: bold; -} -article.interview > header > .actions > a.view-source { - background-color: #666; -} -article.interview > header > .actions > a.view-source:hover { - background-color: #3A3; -} -article.interview > header > .actions > a.edit:hover { - background-color: #66F; -} - -/* Interview Timecodes */ - -article.interview .timecode { - font-size: 0.8em; - margin-left: -10%; - position: absolute; - right: 103%; - transition: opacity .2s; -} -article.interview .timecode > a.external { - text-decoration: none; - color: black; - opacity: 0.4; - transition: all .2s; -} -article.interview .timecode > a.external:hover { - opacity: 1; -} -article.interview .timecode > a.internal { - transform: rotate(90deg); - position: absolute; - right: 80%; - opacity: 0; - transition: all .2s; - top: -5px; - width: 16px; - height: 16px; - padding: 2px; - border-radius: 1000px; -} -article.interview .timecode:hover > a.internal { - opacity: 0.3; -} -article.interview .timecode:hover > a.internal:hover { - opacity: 1; -} - - -/* Interview speakers */ - -article.interview > main > .speaker { - margin-top: 20px; - position: relative; -} -article.interview > main > .speaker > .speaker-name { - font-size: 0.8em; - opacity: 0.6; - position: relative; - top: 15px; -} -article.interview > main > .speaker > p { - margin: 20px 0; -} - - -/* Interview mentions */ - -article.interview > main .mention > a { - font-weight: normal; - transition: all .1s; - text-decoration: underline; - font-weight: bold; - color: blue; -} -article.interview > main .mention a:hover { - text-decoration: underline; - filter: brightness(1.5); -} - - -/* Interview quotes */ - -article.interview > main blockquote > * { - padding-left: 20px; - font-style: italic; -} - - -/* Homepage Content */ - -article.homepage #content > .content { - margin-bottom: 50px; - position: relative; -} -article.homepage #content > .content > .header { - margin-bottom: 20px; -} -article.homepage #content > .content > .header > a.title { - text-decoration: none; - color: black; - font-weight: bold; - font-size: 1.5em; - display: block; - margin-bottom: 5px; -} -article.homepage #content > .content > .header > a.title:hover { - text-decoration: underline; - filter: brightness(1.2); -} -article.homepage #content > .content > .header > a.todo.title { - color: #aaa; - text-decoration: line-through; -} -article.homepage #content > .content > .header > .hud { - margin-bottom: 1px; -} -article.homepage #content > .content > .header > .hud > .date { - position: relative; - top: 30px; - float: left; - clear: left; - margin-left: -17%; - width: 100px; - text-align: right; - font-family: "Courier New"; - color: #999; - font-size: 0.9em; -} -article.homepage #content > .content > .header > .hud > a.series { - font-size: 0.9em; - color: #444; -} -article.homepage #content > .content > .header > .hud > a.series:hover { - color: black; - text-decoration: underline; -} -article.homepage #content > .content > .body > .mention > a { - transition: all .1s; - text-decoration: none; - font-weight: normal; - - font-size: 1em; - border-radius: 10px; - margin-right: 2px; - font-weight: normal; - text-decoration: underline; - color: #333; -} -article.homepage #content > .content > .body > .mention > a:hover { - color: blue; - text-decoration: underline; -} - - -/* Homepage HUD */ - -article.homepage .homepage-hud { - margin-bottom: 5px; -} -article.homepage .homepage-hud > a.github-project { - margin-right: 15px; -} -article.homepage .homepage-hud > a.github-project img { - width: 40px; -} -article.homepage .homepage-hud > .github-sponsor { - display: inline-block; - position: relative; - top: -12px; -} - - -/* Mention Details Page elements */ - -article.mentions h1 a { - color: black; - font-size: 1.5em; -} -article.mentions h1 a:hover { - text-decoration: underline; -} -article.mentions h2 { - font-size: 1em; - font-weight: normal; - margin-top: 20px; - margin-bottom: 10px; -} -article.mentions ul { - list-style: disc; - margin-left: 20px; -} -article.mentions .content > a { - color: blue; - text-decoration: underline; -} - - -/* Inline Mention Popup Card */ - -.mention .popup-card { - position: absolute; - width: 400px; - z-index: 2000; -} - -.mention .popup-card { - display: none; -} -.mention:hover .popup-card { - display: inherit; -} - -.mention .popup-card .popup-select { - background-color: #fff; - border: 2px solid #000; - border-radius: 6px; - max-height: 400px; - overflow-y: auto; - padding: 0; - - width: 100%; - margin: 0; - padding-bottom: 20px; -} - -.mention .popup-card .popup-select h1 { - font-size: 1.5em; - font-weight: bold; - margin: 0; - padding: 20px; - border-bottom: 1px solid #ddd; - margin-bottom: 20px; -} -.mention .popup-card .popup-select h1 > a { - color: black; -} -.mention .popup-card .popup-select h1 > a:hover { - text-decoration: underline; -} -.mention .popup-card .popup-select h2 { - font-size: 0.9em; - line-height: 1.2em; - font-weight: normal; - color: #444; - margin: 20px 0 0 0; - padding: 8px 20px 8px 20px; - word-break: break-word; -} -.mention .popup-card .popup-select h2 a { - color: #666; - text-decoration: underline; -} -.mention .popup-card .popup-select h2 a:hover { - color: #000; -} -.mention .popup-card .popup-select > ul { - margin: 0 20px; -} -.mention .popup-card .popup-select ul { - list-style-type: circle; - padding: 0 20px 0 20px; - word-break: break-word; -} -.mention .popup-card .popup-select .content a { - color: #000; - text-decoration: underline; -} -.mention .popup-card .popup-select .content a:hover { - color: blue; - text-decoration: underline; -} - - -/* Media queries */ - -@media only screen and (max-width: 732px) { - article header, - article div.speaker, - article section, - body footer, - #top-bar a { - width: 100%; - margin-left: 0; - } - - article .sidenote { - position: relative; - display: none; - float: left; - clear: both; - vertical-align: baseline; - width: 95%; - margin: 30px - } - article .sidenote.sidenote-standalone { - display: block; - width: 100%; - margin: 10px 0; - } - article .sidenote:before { - right: 101%; - } - article .sidenote-toggle:checked + .sidenote { - display: block; - } - article label.sidenote-toggle { - cursor: pointer; - } -} -@media only screen and (max-width: 1400px) { - article.homepage #content > .content > .header > .hud > .date { - float: none; - clear: none; - margin-left: 0; - padding-right: 5px; - font-family: inherit; - top: 0; - - } - article.homepage #content > .content > .header > .hud > .date:after { - content: " /"; - } - article.interview .timecode { - position: relative; - right: 0; - margin-left: 0; - top: 0; - margin-right: 3px; - } - article.interview .timecode a.internal { - top: -10px; - } -}