-
-
Notifications
You must be signed in to change notification settings - Fork 131
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
🚧 Resolves #399, allow class and class instance to be registered as extensions #412
Conversation
5f98ae6
to
e01df71
Compare
I did. (https://gitlab.com/antora/antora/blob/master/packages/asciidoc-loader/lib/include/include-processor.js) But I get your point, it's not ideal. |
I like the API where you define a class name and a map of functions. This feels the most natural and makes it easy to add methods to the class that aren't part of the API. Perhaps if only a function is specified (not a map of functions), we can assume it's the process method, but that's up to you. For the class name, we should support namespaces (e.g., |
We should try to rewrite the include processor in Antora to see how the model fits. For that use case, I needed to pass state to the initialize method, so that's something to consider. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the API where you define a class name and a map of functions.
Thanks for your feedback 😃
That's a good point, I will have a look. |
This API is working great for simple extension but if you want to add a state then you have to play with the scope. I do have a solution but the instance is shared between functions: // define a constant
const $callback = Symbol('callback');
let includeProcessor = asciidoctor.Extensions.createIncludeProcessor('StaticIncludeProcessor', {
setCallback: (callback) => {
// Register the callback on the instance
includeProcessorInstance[$callback] = callback;
},
process: (doc, reader, target, attrs) => {
// Use the callback defined on the instance
reader.pushInclude([includeProcessorInstance[$callback]('pass')], target, target, 1, attrs);
}
});
// Instanciate an IncludeProcessor
let includeProcessorInstance = includeProcessor.$new();
includeProcessorInstance['$setCallback'](value => 'you should ' + value);
registry.includeProcessor(includeProcessorInstance); As far as I know, it's not possible to override |
I don't think overriding the initialize function is a hard requirement, especially since objects are open. We just need an opportunity to either hook into instantiation or pass state. We could even do both. For the first approach, we could take a page from Java EE and reserve the postConstruct function. The custom class would override the initialize method internally and call this function at the end of the method (or however else we decide to do it). For the second approach, we could allow an arbitrary object to be passed as the third argument. This would get stored in the instance variable "state" and could be accessed from the process method using That would cover both requirements and still let us use the simple design of this API. wdyt? |
Excellent ideas! 💯 |
Opal.defn(scope, '$' + key, functions[key].bind(scope)); In the following example, we can see that const $callback = Symbol('callback');
let includeProcessor = asciidoctor.Extensions.createIncludeProcessor('StaticIncludeProcessor', {
postConstruct: (context) => {
this[$callback] = (value => 'you should ' + value);
},
process: (doc, reader, target, attrs) => {
reader.pushInclude([this[$callback]('pass')], target, target, 1, attrs);
}
});
let includeProcessorInstance = includeProcessor.$new();
this[$callback] = (value) => value; // ERROR: it will override the function defined in the postConstruct function
registry.includeProcessor(includeProcessorInstance); EDIT: It's working when I define a function and I call this function directly: let userFunction = functions[key];
Opal.defn(scope, '$' + key, function() {
userFunction.bind(this).apply(this, arguments);
}); let includeProcessor = asciidoctor.Extensions.createIncludeProcessor('StaticIncludeProcessor', {
hello: function() {
console.log('hello', this);
}
});
let includeProcessorInstance = includeProcessor.$new();
includeProcessorInstance.$hello(); In the above example, |
I read diagonally this thread. But one question @Mogztter what is the purpose of that statement?
As of myself, I would write either:
Or did I missed something? |
@s-leroux I'm trying to bind the extension context on this function but it's not working... I've tried If you want to give it a try, your help is more than welcome 🌈 A simplified example: let includeProcessor = asciidoctor.Extensions.createIncludeProcessor('StaticIncludeProcessor', {
postConstruct: (context) => {
this['message'] = 'hello'; // register message property on the include processor instance
},
process: (doc, reader, target, attrs) => {
reader.pushInclude([this['message']], target, target, 1, attrs); // this['message'] returns 'hello'
}
}); |
Thanks for the reply @Mogztter When you said "context"--is this a JS context for JS consumption exclusively? Or do you expect a two way binding so that context would be accessible/modifiable from the Opal/Ruby side too? Anyway, I could probably tackle into that. The only obstacle is I'm not familiar with the AsciiDoctor extensions nor "IncludeProcessor". If you had a simple self-contained Opal or Ruby usage example of custom processor, that could serve me as a starting point. |
What about that: /**
Create a new Opal class defining the $m and $self method for
testing purposes.
*/
const opalClass = function() {
const C = Opal.Class.$new(Opal.Object /*, function(){} */);
Opal.defn(C, '$m', function(){ return "original"; });
Opal.defn(C, '$self', function(){ return this; });
return C;
};
/**
Test case: override a method from an Opal class, with support to call the inherited/super
implementation:
*/
it("should allow calling the inherited method", function() {
const base = opalClass();
// ---------------------------------------------------------
// This is the interesting part:
// ---------------------------------------------------------
const sub = outils.subclass(base, {
'$m': function() {
return "overridden+" + this.inherited.$m();
},
});
// ---------------------------------------------------------
const obj = sub.$new();
assert.equal(obj.$m(), 'overridden+original');
}); |
We are just experimenting at this point 😛
It's a JS context or more precisely a JS variable available in the scope.
That would be great!
If I explain the code step by step maybe it will help you to understand. var superclass = Opal.const_get_qualified(Extensions, 'IncludeProcessor');
var scope = Opal.klass(Opal.Object, superclass, name, function () {}); The second step, is to use Opal.defn(scope, '$handles?', function () { return true; }); I can see that you are already familiar with The only difference is that you are creating a new class that does not extend a superclass: C = Opal.Class.$new(Opal.Object /*, function(){} */); So that's what the code does.
Each functions will be "register" to the class using In your code I can see that you are using an instance variable This is exactly what we want to do. asciidoctor.Extensions.createIncludeProcessor('StaticIncludeProcessor', {
postConstruct: () => {
this.templates = templates;
},
handles: (name) => {
return this.templates.has(name);
}
}); |
@s-leroux Thanks for you input. I think I found my mistake!
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions This is the reason why |
spec/node/asciidoctor.spec.js
Outdated
it('should be able to register a include processor class with a state', function () { | ||
const registry = asciidoctor.Extensions.create(); | ||
const $callback = Symbol('callback'); | ||
let includeProcessor = asciidoctor.Extensions.createIncludeProcessor('StaticIncludeProcessor', { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mojavelinux @s-leroux What do you think about the method signature ?
Thanks for the help. Actually, I think I've already written a somewhat similar but generic implementation to specialize Opal classes: https://github.com/s-leroux/asciidoctor.js-pug/blob/opal-utils/lib/opal-utils.js This module currently exports two methods:
It should be easy to rewrite your Currently, I'm using the |
👍 This is actually very close to the low level API described above: Low level API var includeProcessorClass = asciidoctor.Class.create(Extensions, 'IncludeProcessor');
asciidoctor.Class.define(includeProcessorClass, 'process', function (doc, reader, target, attrs) {
reader.pushInclude(['included content'], target, target, 1, attrs)
});
As suggested by @mojavelinux, we should support namespaces for the class name (e.g., I think the API should also hide the
Indeed!
Excellent! @mojavelinux What do you think ? |
This trips up so many people, including myself until I finally hammered it into my brain. It's common because it's so unexpected. Once it bites you a few times, you never forget it. |
I really like the API @s-leroux has proposed. I also agree with @Mogztter that the leading I still think, though, that we should continue to pursue the idea of a DSL-like API for defining an extension class. Underneath, it can still use the class extension utility @s-leroux created, but the extension author doesn't need to know that. Of course, if they want, they can still use the low-level class definition API directly. |
👍 |
@mojavelinux, @Mogztter once again, thank you both for your support and for your time! postContruct vs $initializeasciidoctor.Extensions.createIncludeProcessor('StaticIncludeProcessor', {
postConstruct: () => {
this.templates = templates;
},
handles: (name) => {
return this.templates.has(name);
}
}); What is the purpose exactly of the Concerning Indeed this is close to what I written myself. An issue though is you lose the original method. So I can't call the base implementation from a specialized one. For example, I would like to be able to write something like that: let MySpecializedProcessor = asciidoctor.Extensions.createIncludeProcessor('MyBaseProcessor', {
initialize: () => {
this.inherited.initialize(); // call base constructor
doThisAndThat();
}
process: (doc, reader, target, attrs) => {
if (thisOrThat)
doSomething();
else
this.inherited.process(doc,reader,target,attrs); // or this.super.process(...)
}
}); API
I would push toward a solution based on a low-level API and then build a more user-friendly DLS-like API on top of that for the most common use cases. Such separation would probably help in testing and maintenance, and, as you said, by providing the low-level API, users with special needs would still have some options before having to dig into Opal. The case of the $-methods
I've ignored that until now because I wasn't sure how/why those '$' are appearing before the method names. At some point, I considered adding "behind the scene" the '$' to the user-provided methods: let C = subclass(base, {
process: function() { ... },
}); In the C class above, the user-provided
let C = subclass(base, {
process: function() { if (thisOrThat) this.inherited.$process(); },
});
let C = subclass(base, {
name: function() { return thisOrThat; }, // OUPS: will override $name by mistake
}); I would suggest the low-level API to not try doing anything special for the But the high-level API could provide a wrapper for the few methods expected to be overriden from JS. So developers using only the high-level API for well defined use cases will never see a $-method. We could achieve that by forwarding the In pseudo-code: Extensions.createIncludeProcessor = function (name, functions) {
// call the low-level API to create a derived class
C = createNewDerivedClass(name);
// wrap $-methods expected to overriden by the user into plain JavaScript functions:
// do that for each method
// (based on the assumption addMethodForClass will return the base implementation)
const $original_process = addMethodForClass(C, "$process", function(...) { this.process(....) }); // the $-method forwards to the non-$ method
addMethodForClass(C, "process", $original_process); // the non-$ method receive the original implementation (or undefined, btw)
// override methods with user provided function
for([name, fn] in functions) addMethodForClass(C, name, fn);
} If I'm not too wrong with that kind of wrapper, calling Or did I missed something? |
The '$' prefix is just Opal's generic way of not stepping on existing symbols. It means "Ruby method". But in this context, everything is a Ruby method (since we are defining a Ruby class), so we don't need to leak this detail. It's an abstraction that is leaking out of Opal that is just making things ugly and providing no other benefit. |
It's the name of the class constructor method in Ruby. (minus the leading $ of course)
That's precisely what the postConstruct method is for. But now that I think about it a bit more, we already have control over the definition of the class, so we might as well just use the name "initialize" and not try to be fancy. So let's just call it "initialize". Internally, this will need to be responsible for either invoking the constructor or running just after it (if there's some technical limitation). |
Indeed! Community FTW! |
I believe it's possible to bind a |
Actually, once we can call the super/inherited methods, we can call the base
OK. Here is a possible implementation (I used @Mogztter @mojavelinux I encourage you to take a look at the code since this works surprisingly well to override methods and constructors from JS while still allowing the specialized code to call the inherited method using the syntax it("should allow calling the inherited method", function() {
const base = opalClass();
const sub = outils.subclass(base, {
'$m': function() {
return "overridden+" + this.inherited(base, '$m')();
},
});
const obj = sub.$new();
assert.equal(obj.$m(), "overridden+original");
});
it("should allow calling the inherited constructor", function() {
const base = opalClass();
const sub = outils.subclass(base, {
'$initialize': function() {
this.derivedInitializeCalled = true;
this.inherited(base, '$initialize')();
},
});
const obj = sub.$new();
assert.isTrue(obj.baseInitializeCalled);
assert.isTrue(obj.derivedInitializeCalled);
});
it("should allow calling the inherited method [several levels of subclassing]", function() {
const base = opalClass();
const sub = outils.subclass(base, {
'$m': function() {
return "overridden+" + this.inherited(base, '$m')();
},
});
const sub2 = outils.subclass(sub, {
'$m': function() {
return "level3+" + this.inherited(sub, '$m')();
},
});
const obj = sub2.$new();
assert.equal(obj.$m(), "level3+overridden+original");
}); ( |
I think @Mogztter was suggesting there is a technical reason why we cannot, though I'm not sure I understand what that limitation is since I was able to do it with some hackery in Antora. |
Seems completely fine to me 👍 |
Lot of ideas, I love it! 💡 super/inheritedOpal is using the following code to call Opal.send(this, Opal.find_super_dispatcher(this, 'initialize', initialize)); @s-leroux Is there a reason why you choose to use So 👍 for 💡 postContruct vs $initializeIt was a workaround because postConstruct: () => {
this.templates = templates;
} than: initialize: () => {
Opal.send(this, Opal.find_super_dispatcher(this, 'initialize', initialize)); // user: "What is this boilerplate... I don't even understand this code"
this.templates = templates;
} or even: initialize: () => {
this.inherited.initialize();
this.templates = templates;
} We could call
So I'm leaning toward 💡 $-methods
I 100% agree. 📔 Summary
@mojavelinux @s-leroux Do you agree ? |
super/inherited
Of course: I wasn't aware of postContruct vs $initializeA more fair comparaison would be between: postConstruct: () => {
this.templates = templates;
} versus (don't pay attention to the $initialize: () => {
this.templates = templates;
this.inherited(base, '$initialize')()
} Besides that, I have several issues with the Calling the base constructor with a modified parameter listThere is one use case you can't address with $initialize: (x) => {
x = somehowModify(x);
const y = inferSomeDefaultValue();
this.inherited(base, '$initialize')(x, y);
} I think the closest Ruby equivalent would be calling New paradigm
JavaScript developers already know they should explicitly call the prototype/super constructor. From what I saw, Ruby developers know they should explicitly call Failing earlyAnother corner case is you can't fail early after sanity check: postConstruct: () => {
if (cantWorkProperly)
throw SomeError();
} vs $initialize: () => {
if (cantWorkProperly)
throw SomeError();
this.inherited(base, '$initialize')()
} With the $-methodsI think we agree. No $ in the high level API. The high-level API has knowledge of the intended use case. So it is in position to hide some implementation details or awkward syntax. The low level API should be a rebust base foundation so you can build the high-level API without bothering with too many Opal-related stuff. On the other hand, the low-level API should be as transparent as possible by not making too many assumptions on what the developers want to do. |
You've done a lot of great work @Mogztter. And you have a deep knowledge of Opal and Asciidoctor. On my side, I may have more ideas about how to bind JavaScript in doing what we need. So, I suggest we work on a client-provider relationship: For example, is such interface suitable for you? createSubclass('SomeBaseClassName', {
$initialize: () => {
this.templates = templates;
},
$doThis: (name) => {
return this.templates.has(name);
}
}); Could you refactor Ideally, the low-level API could be developed as part of a different project/{sub,}repository, so we will be forced to stick with the defined interface without being tempted to tweak the other side of the mirror. On my side, I could use the same low-level API for |
postContruct vs $initialize
In my opinion,
But maybe I'm biased because I'm from the Java EE world and So, as you mentioned, the real question is: Is it worth it to introduce this new paradigm ? Since there's a debate, we could use the YAGNI principle and leave it for now. @mojavelinux @oncletom @hsablonniere Do you have an opinion ?
I think we should move forward with this issue and see how things are evolving. For now I don't think we should use a low-level API because we are still experimenting and the API is subject to changes.
As long as the low-level API is generic enough and don't do magic, I think we are fine 😃 |
In following this discussion, it seems clear to me that postConstruct and initialize are not mutually exclusive. They have different goals and are thus both important. What @Mogztter is trying to do is provide a lifecycle hook for converters that are only enhancing an existing converter. This is a very different use case from creating a brand new converter from scratch, which will likely need fine-grained control over the initialize method (and its call to super). The postConstruct is only used if present, so you can have both, one or the other, or neither. And that seems like a pretty good deal to me. I agree strongly with @s-leroux that we want the APIs to feel as natural as possible to a JavaScript developer. And we're fortunate to have your input on this design, @s-leroux. And open question in my mind is where to put the API for creating Ruby classes. Ideally, it would go in Opal itself, though I don't necessarily want to impose our needs on that project. So perhaps what we're looking at is something like opal-class-builder or something that we can than depend on in Asciidoctor.js. It doesn't make sense to be for it to live in Asciidoctor.js or a downstream from it. It really belongs upstream from Asciidoctor.js. The high-level API belongs in Asciidoctor.js as it's providing a shorthand for making extension classes. |
We could ask on Gitter: https://gitter.im/opal/opal 😃 |
We could definitely ask. And I'm not saying that Opal won't be open to the idea. But it feels to me like we would be imposing because this is a higher-level thing than what Opal needs to be concerned about. Opal is not really trying to provide a porcelain API. It's just dealing with the transpilation. Perhaps this could end up being a subproject of Opal. That would certainly make sense. |
@Mogztter @mojavelinux Thank you for your replies. Indeed, I have much lower-level needs than the scope of Since I need such low-level interface and I have already written and tested it (on Node.js) I was pushing for that to be included "somewhere" upstream instead of creating a new derived project just for that. But, I've somewhat missed the point the goal of |
I pinged Elia and as expected they are very open minded about this idea 😛
@s-leroux You can definitely share your ideas with them!
But we are working together ❤️ |
Exactement ! |
src/asciidoctor-extensions-api.js
Outdated
return scope; | ||
}; | ||
|
||
Extensions.newIncludeProcessor = function (name, functions) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mojavelinux What do you think about these functions ? The only purpose is to hide $new()
... do you have a better idea ? should we remove these functions for now ?
I want to merge this changes before the first release of Asciidoctor.js |
I arrive a bit late in the game, sorry 😅 Is there any reason not to use native ECMAScript classes/objects ? const {BaseConverter} = require('asciidoctor.js/converter');
const Asciidoctor = require('asciidoctor.js')();
class TextConverter extends BaseConverter {
constructor(backend, options) {
super(doc, options);
this.something = options.foo || 100;
}
convert(node, transform, args) {
// ...
}
}
Asciidoctor.Converter.Factory.$register(new TextConverter('txt')); |
In theory we could wrap Opal classes into ECMAScript classes but I'm pretty sure it's not possible to extend Opal classes. The reason is that Opal uses "special" classes because the object model in Ruby is largely different. But it gave me an idea, I need to check if it's possible 🤓 |
I think it's fair to say that nobody will create a class using Opal syntax:
We could create a low level API to declare a class and define method on this class or we could pass a list of methods to register:
Low level API
List of methods
@mojavelinux Once we agreed, I will implement the remaining functions 😉